mobile-debug-mcp 0.12.2 → 0.12.3

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.
@@ -11,7 +11,17 @@ export async function prepareGradle(projectPath) {
11
11
  const gradlewPath = path.join(projectPath, 'gradlew');
12
12
  const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
13
13
  const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
14
- const gradleArgs = ['assembleDebug'];
14
+ // Start with a default task; callers may append/override via env flags
15
+ const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
16
+ // Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
17
+ const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
18
+ if (workers) {
19
+ gradleArgs.push(`--max-workers=${workers}`);
20
+ }
21
+ // Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
22
+ if (process.env.MCP_GRADLE_CACHE === '0') {
23
+ gradleArgs.push('-Dorg.gradle.caching=false');
24
+ }
15
25
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
16
26
  const env = Object.assign({}, process.env);
17
27
  if (detectedJavaHome) {
@@ -72,8 +72,17 @@ export class iOSManage {
72
72
  catch { }
73
73
  return null;
74
74
  }
75
+ // Prepare build flags and paths (support incremental builds)
75
76
  let buildArgs;
76
77
  let chosenScheme = null;
78
+ // Derived data and result bundle (agent-configurable)
79
+ const derivedDataPath = process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
80
+ const resultBundlePath = path.join(projectRootDir, 'build', 'xcresults', 'ResultBundle.xcresult');
81
+ const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4;
82
+ const forceClean = process.env.MCP_FORCE_CLEAN === '1';
83
+ // ensure result dirs exist
84
+ await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
85
+ await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
77
86
  if (workspace) {
78
87
  const workspacePath = path.join(projectRootDir, workspace);
79
88
  chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
@@ -86,16 +95,27 @@ export class iOSManage {
86
95
  const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
87
96
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
88
97
  }
98
+ // Insert clean if explicitly requested via env
99
+ if (forceClean) {
100
+ const idx = buildArgs.indexOf('build');
101
+ if (idx >= 0)
102
+ buildArgs.splice(idx, 0, 'clean');
103
+ }
89
104
  // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
90
105
  if (destinationUDID) {
91
106
  buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
92
107
  }
93
- // Add result bundle path for diagnostics
108
+ // Add derived data and result bundle for diagnostics and faster incremental builds
109
+ buildArgs.push('-derivedDataPath', derivedDataPath);
110
+ buildArgs.push('-resultBundlePath', resultBundlePath);
111
+ // parallelisation and jobs
112
+ buildArgs.push('-parallelizeTargets');
113
+ buildArgs.push('-jobs', String(xcodeJobs));
114
+ // Prepare results directory for backwards-compatible logs
94
115
  const resultsDir = path.join(projectPath, 'build-results');
95
116
  // Remove any stale results to avoid xcodebuild complaining about existing result bundles
96
117
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
97
118
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
98
- // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
99
119
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
100
120
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
101
121
  const tries = MAX_RETRIES + 1;
package/dist/ios/utils.js CHANGED
@@ -239,6 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
239
239
  }
240
240
  export async function listIOSDevices(appId) {
241
241
  return new Promise((resolve) => {
242
+ // Query all devices and separately query booted devices to mark them
242
243
  execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
243
244
  if (err || !stdout)
244
245
  return resolve([]);
@@ -247,32 +248,50 @@ export async function listIOSDevices(appId) {
247
248
  const devicesMap = data.devices || {};
248
249
  const out = [];
249
250
  const checks = [];
250
- for (const runtime in devicesMap) {
251
- const devices = devicesMap[runtime];
252
- if (Array.isArray(devices)) {
253
- for (const device of devices) {
254
- const info = {
255
- platform: 'ios',
256
- id: device.udid,
257
- osVersion: parseRuntimeName(runtime),
258
- model: device.name,
259
- simulator: true
260
- };
261
- if (appId) {
262
- // check if installed
263
- const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
264
- .then(() => { info.appInstalled = true; })
265
- .catch(() => { info.appInstalled = false; })
266
- .then(() => { out.push(info); });
267
- checks.push(p);
251
+ // Get booted devices set
252
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
253
+ const bootedSet = new Set();
254
+ if (!err2 && stdout2) {
255
+ try {
256
+ const bdata = JSON.parse(stdout2);
257
+ const bmap = bdata.devices || {};
258
+ for (const rt in bmap) {
259
+ const devs = bmap[rt];
260
+ if (Array.isArray(devs))
261
+ for (const d of devs)
262
+ bootedSet.add(d.udid);
268
263
  }
269
- else {
270
- out.push(info);
264
+ }
265
+ catch { }
266
+ }
267
+ for (const runtime in devicesMap) {
268
+ const devices = devicesMap[runtime];
269
+ if (Array.isArray(devices)) {
270
+ for (const device of devices) {
271
+ const info = {
272
+ platform: 'ios',
273
+ id: device.udid,
274
+ osVersion: parseRuntimeName(runtime),
275
+ model: device.name,
276
+ simulator: true,
277
+ booted: bootedSet.has(device.udid)
278
+ };
279
+ if (appId) {
280
+ // check if installed
281
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
282
+ .then(() => { info.appInstalled = true; })
283
+ .catch(() => { info.appInstalled = false; })
284
+ .then(() => { out.push(info); });
285
+ checks.push(p);
286
+ }
287
+ else {
288
+ out.push(info);
289
+ }
271
290
  }
272
291
  }
273
292
  }
274
- }
275
- Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
293
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
294
+ });
276
295
  }
277
296
  catch {
278
297
  resolve([]);
package/dist/server.js CHANGED
@@ -120,6 +120,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
120
120
  type: "object",
121
121
  properties: {
122
122
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from appPath/project files." },
123
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
123
124
  appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
124
125
  deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
125
126
  },
@@ -133,6 +134,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
133
134
  type: "object",
134
135
  properties: {
135
136
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
137
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
136
138
  projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
137
139
  variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
138
140
  },
@@ -414,8 +416,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
414
416
  return wrapResponse(response);
415
417
  }
416
418
  if (name === "install_app") {
417
- const { platform, appPath, deviceId } = args;
418
- const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId });
419
+ const { platform, projectType, appPath, deviceId } = args;
420
+ const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId, projectType });
419
421
  const response = {
420
422
  device: res.device,
421
423
  installed: res.installed,
@@ -425,13 +427,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
425
427
  return wrapResponse(response);
426
428
  }
427
429
  if (name === "build_app") {
428
- const { platform, projectPath, variant } = args;
429
- const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
430
+ const { platform, projectType, projectPath, variant } = args;
431
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant, projectType });
430
432
  return wrapResponse(res);
431
433
  }
432
434
  if (name === 'build_and_install') {
433
- const { platform, projectPath, deviceId, timeout } = args;
434
- const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
435
+ const { platform, projectType, projectPath, deviceId, timeout } = args;
436
+ const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType });
435
437
  // res: { ndjson, result }
436
438
  return {
437
439
  content: [
@@ -3,8 +3,149 @@ import path from 'path';
3
3
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
4
4
  import { AndroidManage } from '../android/manage.js';
5
5
  import { iOSManage } from '../ios/manage.js';
6
+ import { findApk } from '../android/utils.js';
7
+ import { findAppBundle } from '../ios/utils.js';
8
+ import { execSync } from 'child_process';
9
+ export async function detectProjectPlatform(projectPath) {
10
+ try {
11
+ const stat = await fs.stat(projectPath).catch(() => null);
12
+ if (stat && stat.isDirectory()) {
13
+ const files = (await fs.readdir(projectPath).catch(() => []));
14
+ const hasIos = files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'));
15
+ const hasAndroid = files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(projectPath, 'app')).catch(() => null)));
16
+ if (hasIos && !hasAndroid)
17
+ return 'ios';
18
+ if (hasAndroid && !hasIos)
19
+ return 'android';
20
+ if (hasIos && hasAndroid)
21
+ return 'ambiguous';
22
+ return 'unknown';
23
+ }
24
+ else {
25
+ const ext = path.extname(projectPath).toLowerCase();
26
+ if (ext === '.apk')
27
+ return 'android';
28
+ if (ext === '.ipa' || ext === '.app')
29
+ return 'ios';
30
+ return 'unknown';
31
+ }
32
+ }
33
+ catch {
34
+ return 'unknown';
35
+ }
36
+ }
6
37
  export class ToolsManage {
7
- static async buildAppHandler({ platform, projectPath, variant }) {
38
+ static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
39
+ const android = new AndroidManage();
40
+ // prepare gradle options via environment hints
41
+ if (typeof maxWorkers === 'number')
42
+ process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
43
+ if (typeof gradleCache === 'boolean')
44
+ process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
45
+ if (forceClean)
46
+ process.env.MCP_FORCE_CLEAN_ANDROID = '1';
47
+ const task = gradleTask || 'assembleDebug';
48
+ const artifact = await android.build(projectPath, task);
49
+ return artifact;
50
+ }
51
+ static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
52
+ const ios = new iOSManage();
53
+ // silence unused param lints
54
+ void _workspace;
55
+ void _project;
56
+ void _scheme;
57
+ if (derivedDataPath)
58
+ process.env.MCP_DERIVED_DATA = derivedDataPath;
59
+ if (typeof buildJobs === 'number')
60
+ process.env.MCP_BUILD_JOBS = String(buildJobs);
61
+ if (forceClean)
62
+ process.env.MCP_FORCE_CLEAN_IOS = '1';
63
+ if (destinationUDID)
64
+ process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
65
+ const artifact = await ios.build(projectPath);
66
+ return artifact;
67
+ }
68
+ static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
69
+ // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
70
+ const flutterCmd = process.env.FLUTTER_PATH || 'flutter';
71
+ // silence unused params
72
+ void _maxWorkers;
73
+ void _forceClean;
74
+ try {
75
+ // Check flutter presence without streaming output
76
+ execSync(`${flutterCmd} --version`, { stdio: 'ignore' });
77
+ if (!platform || platform === 'android') {
78
+ const mode = buildMode || 'debug';
79
+ try {
80
+ const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' });
81
+ // Try to find built APK
82
+ const apk = await findApk(path.join(projectPath));
83
+ if (apk)
84
+ return { artifactPath: apk, output: out };
85
+ }
86
+ catch (err) {
87
+ const stdout = err && err.stdout ? String(err.stdout) : '';
88
+ const stderr = err && err.stderr ? String(err.stderr) : '';
89
+ throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`);
90
+ }
91
+ }
92
+ if (!platform || platform === 'ios') {
93
+ const mode = buildMode || 'debug';
94
+ try {
95
+ const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' });
96
+ const app = await findAppBundle(path.join(projectPath));
97
+ if (app)
98
+ return { artifactPath: app, output: out };
99
+ }
100
+ catch (err) {
101
+ const stdout = err && err.stdout ? String(err.stdout) : '';
102
+ const stderr = err && err.stderr ? String(err.stderr) : '';
103
+ throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`);
104
+ }
105
+ }
106
+ }
107
+ catch (e) {
108
+ // If flutter CLI not available or command fails, fall back to native subprojects
109
+ // Preserve error message for diagnostics if needed
110
+ void e;
111
+ }
112
+ // Fallback: try native subproject builds
113
+ if (!platform || platform === 'android') {
114
+ const androidDir = path.join(projectPath, 'android');
115
+ const android = new AndroidManage();
116
+ const artifact = await android.build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug');
117
+ return artifact;
118
+ }
119
+ if (!platform || platform === 'ios') {
120
+ const iosDir = path.join(projectPath, 'ios');
121
+ const ios = new iOSManage();
122
+ const artifact = await ios.build(iosDir);
123
+ return artifact;
124
+ }
125
+ return { error: 'Unable to build flutter project' };
126
+ }
127
+ static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
128
+ // silence unused params
129
+ void _maxWorkers;
130
+ void _forceClean;
131
+ // React Native typically uses native subprojects. Delegate to Android/iOS builders.
132
+ if (!platform || platform === 'android') {
133
+ const androidDir = path.join(projectPath, 'android');
134
+ const android = new AndroidManage();
135
+ const artifact = await android.build(androidDir, variant || 'assembleDebug');
136
+ return artifact;
137
+ }
138
+ if (!platform || platform === 'ios') {
139
+ const iosDir = path.join(projectPath, 'ios');
140
+ // Recommend running `pod install` prior to building in CI; not performed automatically here
141
+ const ios = new iOSManage();
142
+ const artifact = await ios.build(iosDir);
143
+ return artifact;
144
+ }
145
+ return { error: 'Unable to build react-native project' };
146
+ }
147
+ static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }) {
148
+ void _projectType;
8
149
  // delegate to platform-specific build implementations
9
150
  const chosen = platform || 'android';
10
151
  if (chosen === 'android') {
@@ -18,8 +159,20 @@ export class ToolsManage {
18
159
  return artifact;
19
160
  }
20
161
  }
21
- static async installAppHandler({ platform, appPath, deviceId }) {
162
+ static async installAppHandler({ platform, appPath, deviceId, projectType }) {
163
+ // Use projectType hint to influence platform detection when explicit platform is not provided
22
164
  let chosenPlatform = platform;
165
+ if (!chosenPlatform && projectType) {
166
+ // Heuristic defaults: KMP, React Native and Flutter commonly target Android by default in CI
167
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
168
+ chosenPlatform = 'android';
169
+ console.debug('[manage] projectType hint -> selecting android by default for', projectType);
170
+ }
171
+ else if (projectType === 'native' || projectType === 'ios') {
172
+ chosenPlatform = 'ios';
173
+ console.debug('[manage] projectType hint -> selecting ios by default for', projectType);
174
+ }
175
+ }
23
176
  try {
24
177
  const stat = await fs.stat(appPath).catch(() => null);
25
178
  if (stat && stat.isDirectory()) {
@@ -106,36 +259,44 @@ export class ToolsManage {
106
259
  return await new iOSManage().resetAppData(appId, resolved.id);
107
260
  }
108
261
  }
109
- static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
262
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }) {
110
263
  const events = [];
111
264
  const pushEvent = (obj) => events.push(JSON.stringify(obj));
112
265
  const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
113
266
  void effectiveTimeout;
114
- // determine platform if not provided by inspecting path
267
+ // determine platform if not provided by inspecting path or projectType hint
115
268
  let chosenPlatform = platform;
116
269
  try {
117
- const stat = await fs.stat(projectPath).catch(() => null);
118
270
  if (!chosenPlatform) {
119
- if (stat && stat.isDirectory()) {
120
- const files = (await fs.readdir(projectPath).catch(() => []));
121
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
122
- chosenPlatform = 'ios';
123
- else
124
- chosenPlatform = 'android';
271
+ // If autodetect is disabled, require explicit platform or projectType
272
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
273
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' });
274
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } };
275
+ }
276
+ // If caller indicated KMP, prefer android by default (most KMP modul8 setups target Android)
277
+ if (projectType === 'kmp') {
278
+ chosenPlatform = 'android';
279
+ pushEvent({ type: 'build', status: 'info', message: 'projectType=kmp -> selecting android platform by default' });
125
280
  }
126
281
  else {
127
- const ext = path.extname(projectPath).toLowerCase();
128
- if (ext === '.apk')
129
- chosenPlatform = 'android';
130
- else if (ext === '.ipa' || ext === '.app')
131
- chosenPlatform = 'ios';
132
- else
133
- chosenPlatform = 'android';
282
+ const det = await detectProjectPlatform(projectPath);
283
+ if (det === 'ios' || det === 'android') {
284
+ chosenPlatform = det;
285
+ }
286
+ else if (det === 'ambiguous') {
287
+ pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' });
288
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } };
289
+ }
290
+ else {
291
+ // Unknown project type - do not guess. Request explicit platform.
292
+ pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' });
293
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } };
294
+ }
134
295
  }
135
296
  }
136
297
  }
137
298
  catch {
138
- chosenPlatform = chosenPlatform || 'android';
299
+ // detection failed; avoid guessing a platform
139
300
  }
140
301
  pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
141
302
  let buildRes;
@@ -49,6 +49,14 @@ export async function resolveTargetDevice(opts) {
49
49
  if (physical.length > 1)
50
50
  candidates = physical;
51
51
  }
52
+ // Prefer booted iOS simulators if present
53
+ if (platform === 'ios') {
54
+ const booted = candidates.filter((d) => !!d.booted);
55
+ if (booted.length === 1)
56
+ return booted[0];
57
+ if (booted.length > 1)
58
+ return booted[0]; // if multiple booted, pick the first
59
+ }
52
60
  candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
53
61
  if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
54
62
  return candidates[0];
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.12.3]
6
+ - Now supports native and cross platform development platforms for building
7
+ - Add MCP_DISABLE_AUTODETECT env var to require explicit platform/projectType for deterministic agent runs. When set to 1, build/install handlers will fail if platform is not provided.
8
+ - Add unit test covering MCP_DISABLE_AUTODETECT behaviour and ambiguous project detection (test/unit/manage/mcp_disable_autodetect.test.ts).
9
+ - Improve build_and_install handler to emit a clear NDJSON event when autodetect is disabled and platform was not supplied.
10
+
11
+
5
12
  ## [0.12.1]
6
13
  - Improve iOS build/install reliability: project auto-scan, explicit simulator destination, configurable watchdog timeout (MCP_XCODEBUILD_TIMEOUT) and retries (MCP_XCODEBUILD_RETRIES), and DerivedData fallback for locating .app artifacts.
7
14
  - Make install_app capable of building iOS projects before installing so agents can autonomously fix, build, install and validate apps.