mobile-debug-mcp 0.35.0 → 0.36.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.
Files changed (112) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +86 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
  19. package/dist/debugger/guard.js +4 -4
  20. package/dist/debugger/ios-worker.js +62 -0
  21. package/dist/debugger/ios.js +57 -0
  22. package/dist/debugger/sessions.js +33 -16
  23. package/dist/debugger/worker.js +6 -4
  24. package/dist/interact/index.js +12 -2
  25. package/dist/manage/ios.js +2 -0
  26. package/dist/observe/ios-trace/artifact.js +63 -0
  27. package/dist/observe/ios-trace/capabilities.js +86 -0
  28. package/dist/observe/ios-trace/native.js +93 -0
  29. package/dist/observe/ios-trace/notification.js +35 -0
  30. package/dist/observe/ios-trace/process.js +98 -0
  31. package/dist/observe/ios-trace/sessions.js +244 -0
  32. package/dist/observe/readiness-service.js +1 -1
  33. package/dist/observe/readiness.js +4 -4
  34. package/dist/observe/system-trace-error.js +7 -0
  35. package/dist/observe/system-trace-router.js +81 -0
  36. package/dist/observe/system-trace.js +4 -8
  37. package/dist/server/handlers/debugger-handlers.js +27 -8
  38. package/dist/server/handlers/diagnostic.js +12 -4
  39. package/dist/server/handlers/tool-handlers.js +19 -9
  40. package/dist/server/schemas/debugger-schema.js +10 -7
  41. package/dist/server/schemas/system-trace-schema.js +14 -9
  42. package/dist/server-core.js +1 -1
  43. package/dist/server.js +4 -4
  44. package/dist/utils/ios/utils.js +3 -0
  45. package/docs/CHANGELOG.md +23 -1
  46. package/docs/agent-plugin-installation-verification.md +39 -0
  47. package/docs/code-structure.md +3 -1
  48. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  49. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  50. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  51. package/docs/specs/031-ios-system-traces/plan.md +138 -0
  52. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  53. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  54. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  55. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  56. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  57. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  58. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  59. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  60. package/docs/tools/TOOLS.md +2 -2
  61. package/docs/tools/debugger.md +45 -5
  62. package/docs/tools/system-traces.md +81 -14
  63. package/package.json +2 -1
  64. package/scripts/agent-plugin.mjs +55 -5
  65. package/src/debugger/android.ts +1 -1
  66. package/src/debugger/guard.ts +5 -5
  67. package/src/debugger/ios-worker.ts +49 -0
  68. package/src/debugger/ios.ts +45 -0
  69. package/src/debugger/lldb/breakpoints.py +51 -0
  70. package/src/debugger/lldb/inspection.py +109 -0
  71. package/src/debugger/lldb/worker.py +203 -0
  72. package/src/debugger/sessions.ts +24 -12
  73. package/src/debugger/worker.ts +7 -4
  74. package/src/interact/index.ts +13 -2
  75. package/src/manage/ios.ts +2 -0
  76. package/src/observe/ios-trace/artifact.ts +55 -0
  77. package/src/observe/ios-trace/capabilities.ts +74 -0
  78. package/src/observe/ios-trace/native.ts +72 -0
  79. package/src/observe/ios-trace/notification.ts +30 -0
  80. package/src/observe/ios-trace/process.ts +82 -0
  81. package/src/observe/ios-trace/sessions.ts +204 -0
  82. package/src/observe/readiness-service.ts +1 -1
  83. package/src/observe/readiness.ts +4 -4
  84. package/src/observe/system-trace-error.ts +3 -0
  85. package/src/observe/system-trace-router.ts +61 -0
  86. package/src/observe/system-trace.ts +4 -4
  87. package/src/server/handlers/debugger-handlers.ts +28 -9
  88. package/src/server/handlers/diagnostic.ts +11 -4
  89. package/src/server/handlers/tool-handlers.ts +20 -9
  90. package/src/server/schemas/debugger-schema.ts +10 -7
  91. package/src/server/schemas/system-trace-schema.ts +15 -9
  92. package/src/server-core.ts +1 -1
  93. package/src/server.ts +3 -3
  94. package/src/utils/ios/utils.ts +3 -0
  95. package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
  96. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  97. package/test/fixtures/contracts/tool-definitions.json +192 -32
  98. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  99. package/test/fixtures/ios-debugger-app/README.md +27 -0
  100. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  101. package/test/helpers/ios-trace-shutdown.ts +29 -0
  102. package/test/unit/debugger/handle-guards.test.ts +88 -0
  103. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  104. package/test/unit/debugger/ios.test.ts +104 -0
  105. package/test/unit/debugger/lldb-worker.py +166 -0
  106. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  107. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  108. package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
  109. package/test/unit/observe/readiness.test.ts +1 -1
  110. package/test/unit/observe/system-trace-router.test.ts +49 -0
  111. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  112. package/test/unit/server/system-trace.test.ts +12 -3
@@ -1,11 +1,13 @@
1
1
  import { atStage, classify, diagnostic } from './diagnostics.js';
2
2
  import { registerDebuggerPauseProvider } from './guard.js';
3
+ import { IOSDebugNative } from './ios.js';
4
+ import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { AndroidDebugNative } from './android.js';
5
7
  import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining } from './worker.js';
6
8
  export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
7
9
  evaluation: false, mutation: false, coroutines: false, native: false };
8
- const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
10
+ export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
9
11
  export function debuggerExpiry(created, activity, pausedAt, now) {
10
12
  return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
11
13
  }
@@ -13,13 +15,17 @@ export class DebuggerSessions {
13
15
  native;
14
16
  workerFactory;
15
17
  prerequisite;
18
+ options;
16
19
  sessions = new Map();
17
20
  closing = false;
18
- constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker) {
21
+ constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker, options = {}) {
19
22
  this.native = native;
20
23
  this.workerFactory = workerFactory;
21
24
  this.prerequisite = prerequisite;
25
+ this.options = options;
22
26
  }
27
+ get platform() { return this.options.platform ?? 'android'; }
28
+ get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
23
29
  prune() {
24
30
  const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
25
31
  for (const s of terminal)
@@ -42,9 +48,10 @@ export class DebuggerSessions {
42
48
  const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
43
49
  const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
44
50
  return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false, diagnostic: s.cleanup.status === 'failed' ? diagnostic('cleanup', 'operation_failed') : s.diagnostic ?? diagnostic('unknown', 'unknown') } } : {}), debug_session_id: s.id,
45
- device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
46
- capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
51
+ device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
52
+ capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
47
53
  }
54
+ owns(id) { return this.sessions.has(id); }
48
55
  summary(id) { return this.result(this.get(id)); }
49
56
  hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
50
57
  paused(device) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
@@ -77,13 +84,13 @@ export class DebuggerSessions {
77
84
  s.events.shift();
78
85
  }
79
86
  async capabilities(device, app, pid) {
80
- const prerequisites = { jdk: false, device: false, process: false };
87
+ const prerequisites = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
81
88
  let candidates = [];
82
89
  const reasons = [];
83
90
  const diagnostics = [];
84
91
  try {
85
92
  await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
86
- prerequisites.jdk = true;
93
+ prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
87
94
  }
88
95
  catch (e) {
89
96
  if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
@@ -106,9 +113,9 @@ export class DebuggerSessions {
106
113
  reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
107
114
  diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
108
115
  }
109
- return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
116
+ return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
110
117
  captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
111
- ...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
118
+ ...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
112
119
  }
113
120
  async attach(device, app, pid, timeout = 10000) {
114
121
  this.prune();
@@ -144,16 +151,20 @@ export class DebuggerSessions {
144
151
  if (candidates.length > 1)
145
152
  throw new DebuggerError('PROCESS_AMBIGUOUS');
146
153
  s.target = candidates[0];
147
- stage = 'forwarding';
148
- s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target.pid));
149
- remaining();
150
- s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
154
+ if (this.platform === 'android') {
155
+ stage = 'forwarding';
156
+ s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target.pid));
157
+ remaining();
158
+ s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
159
+ }
151
160
  stage = 'worker_start';
152
161
  s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
153
162
  remaining();
154
- s.cleanup.remaining_resources.push('jdi_worker');
163
+ s.cleanup.remaining_resources.push(this.workerResource);
164
+ if (this.platform === 'ios')
165
+ await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
155
166
  stage = 'attach';
156
- await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
167
+ await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker.call('attach', { port: s.port, ...(this.platform === 'ios' ? { pid: s.target.pid, executablePath: s.target.executable_path } : {}), timeoutMs: remaining() }, remaining()));
157
168
  stage = 'target_resolution';
158
169
  await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
159
170
  remaining();
@@ -204,7 +215,7 @@ export class DebuggerSessions {
204
215
  s.worker = undefined;
205
216
  }
206
217
  catch {
207
- remaining.push('jdi_worker');
218
+ remaining.push(this.workerResource);
208
219
  }
209
220
  }
210
221
  if (s.port !== undefined && s.target) {
@@ -252,6 +263,8 @@ export class DebuggerSessions {
252
263
  assertCommandTarget(s, command, args) {
253
264
  if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
254
265
  throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
266
+ if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined))
267
+ throw new DebuggerError('INVALID_ARGUMENT');
255
268
  if (['stack', 'locals', 'step', 'resume'].includes(command)) {
256
269
  if (args.stopId !== s.stop?.stop_id)
257
270
  throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
@@ -268,6 +281,7 @@ export class DebuggerSessions {
268
281
  if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
269
282
  return this.result(s);
270
283
  this.assertCommandTarget(s, command, args);
284
+ const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
271
285
  if (command === 'step' || command === 'resume') {
272
286
  s.state = 'running';
273
287
  s.stop = null;
@@ -278,6 +292,8 @@ export class DebuggerSessions {
278
292
  return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
279
293
  }
280
294
  catch (e) {
295
+ if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code))
296
+ Object.assign(s, previous);
281
297
  return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
282
298
  }
283
299
  }
@@ -301,4 +317,5 @@ export class DebuggerSessions {
301
317
  async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
302
318
  }
303
319
  export const debuggerSessions = new DebuggerSessions();
304
- registerDebuggerPauseProvider(device => debuggerSessions.paused(device));
320
+ export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
321
+ registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
@@ -63,7 +63,7 @@ export async function prepareWorker() {
63
63
  return compiled;
64
64
  }
65
65
  function commandStage(command) { return command === 'attach' ? 'attach' : command === 'detach' ? 'cleanup' : 'command'; }
66
- export class JdiWorker {
66
+ export class DebuggerProcess {
67
67
  onEvent;
68
68
  child;
69
69
  pending = new Map();
@@ -72,9 +72,9 @@ export class JdiWorker {
72
72
  failure;
73
73
  replied = false;
74
74
  exited;
75
- constructor(java, directory, onEvent) {
75
+ constructor(java, directory, onEvent, launch) {
76
76
  this.onEvent = onEvent;
77
- this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] });
77
+ this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) });
78
78
  let buffer = '';
79
79
  this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve(); }); this.child.once('error', () => { this.fail('spawn_failed'); resolve(); }); });
80
80
  this.child.stdin.on('error', () => this.fail('write_failed'));
@@ -172,5 +172,7 @@ export class JdiWorker {
172
172
  }
173
173
  export async function createWorker(onEvent) {
174
174
  const { java, directory } = await prepareWorker();
175
- return new JdiWorker(java, directory, onEvent);
175
+ return new DebuggerProcess(java, directory, onEvent);
176
176
  }
177
+ // Compatibility export for existing JDI transport callers/tests.
178
+ export { DebuggerProcess as JdiWorker };
@@ -105,11 +105,21 @@ export class ToolsInteract {
105
105
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
106
106
  return await interact.tap(x, y, resolved.id, timeoutMs);
107
107
  }
108
+ static _getResolvedElement(elementId, platform, deviceId) {
109
+ const resolved = ToolsInteract._resolvedUiElements.get(elementId);
110
+ if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId))
111
+ return null;
112
+ return resolved;
113
+ }
114
+ static getResolvedElementTarget(elementId, platform, deviceId) {
115
+ const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
116
+ return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null;
117
+ }
108
118
  static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
109
119
  const actionType = 'tap_element';
110
120
  const selector = { elementId };
111
- const resolved = ToolsInteract._resolvedUiElements.get(elementId);
112
- if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
121
+ const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
122
+ if (!resolved) {
113
123
  return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
114
124
  }
115
125
  const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js';
1
2
  import { buildIOS } from './ios/build.js';
2
3
  import { evidenceDispatch } from '../observe/evidence.js';
3
4
  import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
@@ -156,6 +157,7 @@ export class iOSManage {
156
157
  }
157
158
  async function installWithIdb(toInstall, deviceId) {
158
159
  await new Promise((resolve, reject) => {
160
+ checkDebuggerGuard(true);
159
161
  const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
160
162
  let stderr = '';
161
163
  proc.stderr.on('data', d => stderr += d.toString());
@@ -0,0 +1,63 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js';
5
+ import { traceCommand, spawnTrace } from './process.js';
6
+ export const IOS_TRACE_MAX_BYTES = 256 * 1024 * 1024;
7
+ export async function traceSize(root, secure = false) {
8
+ const stat = await fs.lstat(root);
9
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()))
10
+ throw new Error('UNSAFE_ARTIFACT');
11
+ if (secure)
12
+ await fs.chmod(root, stat.isDirectory() ? 0o700 : 0o600);
13
+ if (stat.isFile())
14
+ return stat.size;
15
+ let size = 0;
16
+ for (const name of await fs.readdir(root)) {
17
+ size += await traceSize(path.join(root, name), secure);
18
+ if (size > IOS_TRACE_MAX_BYTES)
19
+ throw new Error('STORAGE_LIMIT');
20
+ }
21
+ return size;
22
+ }
23
+ export function validateToc(xml, device, app) {
24
+ if (XMLValidator.validate(xml) !== true)
25
+ throw new Error('INVALID_TOC');
26
+ const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
27
+ const raw = parsed['trace-toc']?.run;
28
+ if (!raw || Array.isArray(raw))
29
+ throw new Error('INVALID_TOC');
30
+ const target = raw.info?.target;
31
+ if (target?.device?.['@_uuid'] !== device || Number(target.process?.['@_pid']) !== app.pid || raw.info?.summary?.['template-name'] !== 'Time Profiler')
32
+ throw new Error('ARTIFACT_TARGET_MISMATCH');
33
+ const processes = raw.processes?.process;
34
+ if (!(Array.isArray(processes) ? processes : [processes]).some(p => Number(p?.['@_pid']) === app.pid && p?.['@_path'] === app.executable))
35
+ throw new Error('ARTIFACT_APP_MISMATCH');
36
+ const tables = raw.data?.table;
37
+ if (!(Array.isArray(tables) ? tables : [tables]).some(t => t?.['@_schema'] === 'time-sample'))
38
+ throw new Error('SAMPLE_SCHEMA_MISSING');
39
+ }
40
+ export async function validateArtifact(file, device, app, spawn = spawnTrace) {
41
+ const size = await traceSize(file, true);
42
+ if (size <= 0 || size > IOS_TRACE_MAX_BYTES)
43
+ throw new Error('STORAGE_LIMIT');
44
+ const toc = path.join(path.dirname(file), 'toc.xml'), samples = path.join(path.dirname(file), 'samples.xml');
45
+ try {
46
+ await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--toc', '--output', toc], 15000, spawn);
47
+ if ((await fs.stat(toc)).size > 1024 * 1024)
48
+ throw new Error('INVALID_TOC');
49
+ validateToc(await fs.readFile(toc, 'utf8'), device, app);
50
+ await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]', '--output', samples], 15000, spawn);
51
+ if ((await fs.stat(samples)).size > IOS_TRACE_MAX_BYTES)
52
+ throw new Error('STORAGE_LIMIT');
53
+ const xml = await fs.readFile(samples, 'utf8');
54
+ if (XMLValidator.validate(xml) !== true || !xml.includes('<trace-query-result>'))
55
+ throw new Error('INVALID_SAMPLES');
56
+ const sample_rows = (xml.match(/<row(?:\s|>)/g) ?? []).length;
57
+ return { path: file, format: 'instruments', kind: (await fs.stat(file)).isDirectory() ? 'directory' : 'file',
58
+ size_bytes: size, validation: { readable: true, schema: 'time-sample', sample_rows } };
59
+ }
60
+ finally {
61
+ await Promise.all([toc, samples].map(p => fs.rm(p, { force: true })));
62
+ }
63
+ }
@@ -0,0 +1,86 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js';
5
+ import { traceCommand, spawnTrace } from './process.js';
6
+ import { prepareNotification } from './notification.js';
7
+ export const IOS_TEMPLATES = ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'];
8
+ export async function resolveIosApp(device, appId, spawn = spawnTrace) {
9
+ const run = (args) => traceCommand(getXcrunCmd(), args, 10000, spawn);
10
+ const container = await run(['simctl', 'get_app_container', device, appId, 'app']);
11
+ if (!path.isAbsolute(container) || !container.endsWith('.app'))
12
+ throw new Error('APP_IDENTITY_UNAVAILABLE');
13
+ const output = await run(['simctl', 'spawn', device, 'launchctl', 'list']);
14
+ const ids = output.split('\n').filter(line => line.includes(`UIKitApplication:${appId}[`))
15
+ .map(line => Number(line.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1);
16
+ if (ids.length !== 1)
17
+ throw new Error('APP_NOT_RUNNING');
18
+ const executable = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'comm='], 10000, spawn);
19
+ if (!executable.startsWith(container + '/') || executable.includes('\n'))
20
+ throw new Error('APP_IDENTITY_UNAVAILABLE');
21
+ const started_at = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'lstart='], 10000, spawn);
22
+ if (!started_at || started_at.includes('\n'))
23
+ throw new Error('APP_IDENTITY_UNAVAILABLE');
24
+ return { app_id: appId, pid: ids[0], executable, started_at };
25
+ }
26
+ export async function iosCapabilities(device, appId, template = 'Time Profiler', spawn = spawnTrace, host = process.platform) {
27
+ const result = {
28
+ supported: false, backend: 'xctrace', device: null, xcode_version: null,
29
+ prerequisites: { host: host === 'darwin' ? 'available' : 'missing', permissions: 'unknown', symbols: 'unknown' },
30
+ reasons: [], recovery: [], discovered_templates: [], templates: [], app: null,
31
+ limitations: ['App attachment may include sensitive system metadata, paths, logs and signposts.', 'Permissions and symbolication are not established by discovery.', 'Physical iOS capture is not supported in the initial matrix.']
32
+ };
33
+ if (host !== 'darwin') {
34
+ result.reasons.push('MACOS_REQUIRED');
35
+ return result;
36
+ }
37
+ const probe = async (key, fn, recovery) => {
38
+ try {
39
+ await fn();
40
+ result.prerequisites[key] = 'available';
41
+ }
42
+ catch (error) {
43
+ result.prerequisites[key] = 'missing';
44
+ result.reasons.push(error instanceof Error && ['APP_NOT_RUNNING', 'APP_IDENTITY_UNAVAILABLE'].includes(error.message) ? error.message : key.toUpperCase() + '_UNAVAILABLE');
45
+ result.recovery.push(recovery);
46
+ }
47
+ };
48
+ await probe('xcode', async () => { result.xcode_version = await traceCommand(getXcrunCmd(), ['xcodebuild', '-version'], 10000, spawn); }, 'Select a full Xcode installation with xcode-select.');
49
+ await probe('templates', async () => {
50
+ const output = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'templates'], 10000, spawn);
51
+ result.discovered_templates = output.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('=='));
52
+ }, 'Install/select Xcode with Instruments and run xcrun xctrace list templates.');
53
+ await probe('instruments_targets', async () => {
54
+ const targets = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'devices'], 15000, spawn);
55
+ if (!targets.includes(`(${device})`))
56
+ throw new Error('TARGET_UNAVAILABLE');
57
+ }, 'Check xcrun xctrace list devices; use a simulator visible to Instruments.');
58
+ await probe('target', async () => {
59
+ const data = JSON.parse(await traceCommand(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], 10000, spawn));
60
+ const entries = Object.entries(data.devices).flatMap(([runtime, values]) => values.map(value => ({ ...value, runtime })));
61
+ const target = entries.find(d => d.udid === device);
62
+ if (!target || !target.isAvailable || target.state !== 'Booted')
63
+ throw new Error('TARGET_UNAVAILABLE');
64
+ result.device = { platform: 'ios', id: target.udid, simulator: true, model: target.name, osVersion: target.runtime.split('iOS-')[1]?.replaceAll('-', '.') ?? target.runtime };
65
+ }, 'Use an explicit booted simulator UDID. Physical devices and the Mac host are unsupported.');
66
+ await probe('notification_helper', async () => {
67
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-trace-probe-'));
68
+ try {
69
+ await prepareNotification(directory, spawn);
70
+ }
71
+ finally {
72
+ await fs.rm(directory, { recursive: true, force: true });
73
+ }
74
+ }, 'Allow Xcode clang to compile the local Darwin-notification listener.');
75
+ if (appId && result.device)
76
+ await probe('app', async () => { result.app = await resolveIosApp(device, appId, spawn); }, 'Start the requested development app on the selected simulator before tracing.');
77
+ result.templates = IOS_TEMPLATES.map(name => ({ name,
78
+ supported: name === 'Time Profiler' && result.discovered_templates.includes(name) && result.device !== null,
79
+ coverage: name === 'Time Profiler' ? 'CPU samples; bundled Hangs instrument is not independently verified' : name === 'System Trace' ? 'Scheduling coverage unverified' : name === 'Hangs' ? 'Not a standalone template here' : 'Unsupported on tested simulator',
80
+ reason: name === 'Time Profiler' ? null : 'TEMPLATE_TARGET_UNSUPPORTED'
81
+ }));
82
+ if (!result.templates.find(t => t.name === template)?.supported)
83
+ result.reasons.push('TEMPLATE_TARGET_UNSUPPORTED');
84
+ result.supported = result.reasons.length === 0;
85
+ return result;
86
+ }
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { getXcrunCmd } from '../../utils/ios/utils.js';
5
+ import { iosCapabilities, resolveIosApp } from './capabilities.js';
6
+ import { prepareNotification, listenNotification } from './notification.js';
7
+ import { spawnTrace, stopTraceChild, classifyTraceFailure } from './process.js';
8
+ import { traceSize, validateArtifact, IOS_TRACE_MAX_BYTES } from './artifact.js';
9
+ export class IosTraceNative {
10
+ spawn;
11
+ children = new Set();
12
+ recorders = new Set();
13
+ closing = false;
14
+ constructor(spawn = spawnTrace) {
15
+ this.spawn = (command, args, timeout) => {
16
+ if (this.closing)
17
+ throw new Error('TRACE_BUSY');
18
+ const child = spawn(command, args, timeout);
19
+ this.children.add(child);
20
+ if (args.includes('xctrace') && args.includes('record'))
21
+ this.recorders.add(child);
22
+ void child.done.then(() => { this.children.delete(child); this.recorders.delete(child); });
23
+ return child;
24
+ };
25
+ }
26
+ shutdown() {
27
+ this.closing = true;
28
+ for (const child of this.children)
29
+ child.signal('SIGINT');
30
+ const helpers = setTimeout(() => { for (const child of this.children)
31
+ if (!this.recorders.has(child))
32
+ child.signal('SIGKILL'); }, 1000);
33
+ const recorders = setTimeout(() => { for (const child of this.recorders)
34
+ child.signal('SIGKILL'); }, 8000);
35
+ helpers.unref();
36
+ recorders.unref();
37
+ }
38
+ capabilities(device, appId, template) { return iosCapabilities(device, appId, template, this.spawn); }
39
+ resolve(device, app) { return resolveIosApp(device, app, this.spawn); }
40
+ async storage(directory) {
41
+ const stat = await fs.statfs(directory);
42
+ if (stat.bavail * stat.bsize < 1024 * 1024 * 1024)
43
+ throw new Error('STORAGE_LIMIT');
44
+ }
45
+ async start(device, app, directory, duration, own) {
46
+ await this.storage(directory);
47
+ const binary = await prepareNotification(directory, this.spawn);
48
+ return this.startRecorder(device, app, directory, duration, binary, own);
49
+ }
50
+ async startRecorder(device, app, directory, duration, binary, own) {
51
+ const name = 'tools.mobiledebug.trace.' + randomUUID();
52
+ const listener = await listenNotification(binary, name, this.spawn);
53
+ let recording;
54
+ try {
55
+ const current = await this.resolve(device, app.app_id);
56
+ if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
57
+ throw new Error('APP_IDENTITY_CHANGED');
58
+ const file = path.join(directory, 'capture.trace');
59
+ const recorder = this.spawn(getXcrunCmd(), ['xctrace', 'record', '--template', 'Time Profiler', '--device', device,
60
+ '--attach', String(app.pid), '--time-limit', `${duration}ms`, '--output', file, '--notify-tracing-started', name], duration + 65000);
61
+ recording = { recorder, file, ready_at_ms: 0 };
62
+ own(recording);
63
+ await Promise.race([listener.line('READY', 35000), recorder.done.then(result => { throw new Error(classifyTraceFailure(result)); })]);
64
+ if (!recorder.alive())
65
+ throw new Error('RECORDER_EXITED');
66
+ recording.ready_at_ms = Date.now();
67
+ return recording;
68
+ }
69
+ catch (error) {
70
+ if (recording)
71
+ await stopTraceChild(recording.recorder).catch(() => { });
72
+ throw error;
73
+ }
74
+ finally {
75
+ await stopTraceChild(listener, 1000).catch(() => { });
76
+ }
77
+ }
78
+ stop(recording, timeout) { return stopTraceChild(recording.recorder, timeout); }
79
+ async monitor(device, app, recording) {
80
+ const current = await this.resolve(device, app.app_id);
81
+ if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
82
+ throw new Error('APP_IDENTITY_CHANGED');
83
+ try {
84
+ if (await traceSize(recording.file) > IOS_TRACE_MAX_BYTES)
85
+ throw new Error('STORAGE_LIMIT');
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
89
+ throw error;
90
+ }
91
+ }
92
+ validate(recording, device, app) { return validateArtifact(recording.file, device, app, this.spawn); }
93
+ }
@@ -0,0 +1,35 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getXcrunCmd } from '../../utils/ios/utils.js';
4
+ import { traceCommand, spawnTrace, stopTraceChild } from './process.js';
5
+ // A private per-session listener avoids requiring a third-party notification utility.
6
+ const source = `#include <notify.h>
7
+ #include <dispatch/dispatch.h>
8
+ #include <stdio.h>
9
+ #include <stdlib.h>
10
+ int main(int argc, char **argv) {
11
+ if (argc != 2) return 2;
12
+ int token;
13
+ if (notify_register_dispatch(argv[1], &token, dispatch_get_main_queue(), ^(int t) {
14
+ puts("READY"); fflush(stdout); notify_cancel(t); exit(0);
15
+ }) != NOTIFY_STATUS_OK) return 3;
16
+ puts("ARMED"); fflush(stdout); dispatch_main();
17
+ }`;
18
+ export async function prepareNotification(directory, spawn = spawnTrace) {
19
+ const file = path.join(directory, 'notification.c'), binary = path.join(directory, 'notification');
20
+ await fs.writeFile(file, source, { mode: 0o600 });
21
+ await traceCommand(getXcrunCmd(), ['clang', '-fblocks', file, '-o', binary], 30000, spawn);
22
+ await fs.chmod(binary, 0o700);
23
+ return binary;
24
+ }
25
+ export async function listenNotification(binary, name, spawn = spawnTrace) {
26
+ const listener = spawn(binary, [name], 40000);
27
+ try {
28
+ await listener.line('ARMED', 5000);
29
+ return listener;
30
+ }
31
+ catch (error) {
32
+ await stopTraceChild(listener, 1000).catch(() => { });
33
+ throw error;
34
+ }
35
+ }
@@ -0,0 +1,98 @@
1
+ import { spawn } from 'node:child_process';
2
+ const MAX_OUTPUT = 64 * 1024;
3
+ export const spawnTrace = (command, args, timeout) => {
4
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
5
+ let output = '', settled = false, timedOut = false;
6
+ const waiters = new Set();
7
+ let force;
8
+ const terminate = () => {
9
+ if (settled)
10
+ return;
11
+ child.kill('SIGTERM');
12
+ force ??= setTimeout(() => { if (!settled)
13
+ child.kill('SIGKILL'); }, 1000);
14
+ force.unref();
15
+ };
16
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, timeout);
17
+ timer.unref();
18
+ const done = new Promise(resolve => {
19
+ const finish = (code) => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ clearTimeout(timer);
24
+ if (force)
25
+ clearTimeout(force);
26
+ for (const wake of waiters)
27
+ wake();
28
+ resolve({ code, output, timedOut });
29
+ };
30
+ child.on('error', () => finish(null));
31
+ child.on('close', finish);
32
+ const data = (chunk) => {
33
+ output = (output + chunk.toString()).slice(-MAX_OUTPUT);
34
+ for (const wake of waiters)
35
+ wake();
36
+ };
37
+ child.stdout?.on('data', data);
38
+ child.stderr?.on('data', data);
39
+ });
40
+ return {
41
+ done, alive: () => !settled && child.exitCode === null && child.signalCode === null,
42
+ signal: signal => { if (!settled)
43
+ child.kill(signal); },
44
+ line: (text, ms) => new Promise((resolve, reject) => {
45
+ const deadline = setTimeout(() => { waiters.delete(check); reject(new Error('READINESS_TIMEOUT')); }, ms);
46
+ const check = () => {
47
+ if (output.split(/\r?\n/).includes(text)) {
48
+ clearTimeout(deadline);
49
+ waiters.delete(check);
50
+ resolve();
51
+ }
52
+ else if (settled) {
53
+ clearTimeout(deadline);
54
+ waiters.delete(check);
55
+ reject(new Error('RECORDER_EXITED'));
56
+ }
57
+ };
58
+ waiters.add(check);
59
+ check();
60
+ })
61
+ };
62
+ };
63
+ export async function traceCommand(command, args, timeout = 10000, spawn = spawnTrace) {
64
+ const result = await spawn(command, args, timeout).done;
65
+ if (result.code !== 0 || result.timedOut)
66
+ throw new Error(result.timedOut ? 'ACTION_TIMEOUT' : 'COMMAND_FAILED');
67
+ return result.output.trim();
68
+ }
69
+ export async function stopTraceChild(child, timeout = 30000) {
70
+ if (child.alive())
71
+ child.signal('SIGINT');
72
+ let timer;
73
+ const result = await Promise.race([
74
+ child.done,
75
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), timeout); })
76
+ ]);
77
+ if (timer)
78
+ clearTimeout(timer);
79
+ if (result)
80
+ return result;
81
+ child.signal('SIGKILL');
82
+ let forceTimer;
83
+ await Promise.race([child.done, new Promise(resolve => { forceTimer = setTimeout(resolve, 1000); })]);
84
+ if (forceTimer)
85
+ clearTimeout(forceTimer);
86
+ throw new Error('ACTION_TIMEOUT');
87
+ }
88
+ export function classifyTraceFailure(result) {
89
+ if (result.timedOut)
90
+ return 'ACTION_TIMEOUT';
91
+ if (/permission|not permitted|authoriz|privacy|access denied/i.test(result.output))
92
+ return 'PROFILING_PERMISSION_REQUIRED';
93
+ if (/cannot find process|process.*not.*found/i.test(result.output))
94
+ return 'APP_NOT_RUNNING';
95
+ if (/disconnect|device.*unavailable/i.test(result.output))
96
+ return 'TARGET_DISCONNECTED';
97
+ return 'RECORDER_EXITED';
98
+ }