mobile-debug-mcp 0.35.0 → 0.37.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -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 +261 -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 +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -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,7 +1,7 @@
1
1
  import { systemClock } from '../../runtime/clock.js';
2
2
  import { remainingBudget } from '../../utils/operation-budget.js';
3
3
  import { checkDebuggerGuard } from '../../debugger/guard.js';
4
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
4
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
5
  import { readinessDelay } from '../../observe/readiness-service.js';
6
6
  import { _buildUiChangeSignatures, _summarizeUiChangeDelta, _matchesUiChange, _uiChangeSignaturesEqual } from './ui-change-helpers.js';
7
7
  import { createChangeWait, changeResult } from './ui-change-state.js';
@@ -26,12 +26,19 @@ function changed(s, signatures) {
26
26
  s.since = s.clock.now();
27
27
  }
28
28
  const stable = s.since === null ? 0 : s.clock.now() - s.since;
29
- return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.summary.state === 'settled');
29
+ return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.ready);
30
30
  }
31
31
  async function sample(s, deps) {
32
32
  if (s.tracker)
33
33
  remainingBudget();
34
34
  const started = s.clock.now(), a = s.input;
35
+ await observeReadinessDeviceWait();
36
+ if (s.tracker?.deviceReadiness.input_ready === false) {
37
+ s.candidate = null;
38
+ s.change = null;
39
+ s.since = null;
40
+ return null;
41
+ }
35
42
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
36
43
  observeReadinessWait(tree, started);
37
44
  const scoped = deps.scope(tree, a.scope, a.target);
@@ -1,12 +1,13 @@
1
1
  import { systemClock } from '../../runtime/clock.js';
2
+ import { benchmarkStage } from '../../utils/benchmark-metrics.js';
2
3
  import { remainingBudget } from '../../utils/operation-budget.js';
3
4
  import { checkDebuggerGuard } from '../../debugger/guard.js';
4
- import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
+ import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
5
6
  import { readinessDelay } from '../../observe/readiness-service.js';
6
7
  import { matchingElements, evaluateCondition } from './ui-wait-condition.js';
7
8
  import { createUIWait, validateUIWait, waitObserved, waitFailure } from './ui-wait-state.js';
8
9
  async function delay(s, ms) {
9
- await (s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
10
+ await benchmarkStage('poll_sleep_ms', () => s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
10
11
  }
11
12
  function acceptObservation(s, tree, now) {
12
13
  const age = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null;
@@ -15,7 +16,7 @@ function acceptObservation(s, tree, now) {
15
16
  return null;
16
17
  }
17
18
  s.stable++;
18
- if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && s.tracker.summary.state !== 'settled'))
19
+ if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && !s.tracker.ready))
19
20
  return null;
20
21
  return { status: 'success', ...(s.args.singleObservation ? { _tree: tree } : {}), matched: s.count,
21
22
  element: s.element, metrics: { latency_ms: now - s.start, poll_count: s.polls, attempts: s.attempts }, requested: s.requested, observed: waitObserved(s, true) };
@@ -50,6 +51,9 @@ async function poll(s, deps) {
50
51
  s.polls++;
51
52
  try {
52
53
  const started = s.clock.now(), a = s.args;
54
+ await observeReadinessDeviceWait();
55
+ if (s.tracker?.deviceReadiness.input_ready === false)
56
+ return null;
53
57
  const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
54
58
  observeReadinessWait(tree, started);
55
59
  if (a.resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error)))
@@ -1,3 +1,4 @@
1
+ import { validAppId } from '../utils/app-installation.js';
1
2
  import { promises as fs } from 'fs';
2
3
  import path from 'path';
3
4
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
@@ -313,6 +314,8 @@ export class ToolsManage {
313
314
  }
314
315
  }
315
316
  static async listDevicesHandler({ platform, appId }) {
317
+ if (appId !== undefined && !validAppId(appId))
318
+ throw new Error('Invalid appId');
316
319
  const devices = await listDevices(platform, appId);
317
320
  return { devices };
318
321
  }
@@ -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,116 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { pausedReadiness } from '../debugger/guard.js';
3
+ import { execCmd } from '../utils/exec.js';
4
+ import { getAdbCmd } from '../utils/android/commands.js';
5
+ import { getXcrunCmd } from '../utils/ios/utils.js';
6
+ import { remainingBudget } from '../utils/operation-budget.js';
7
+ import { benchmarkStage } from '../utils/benchmark-metrics.js';
8
+ export function unknownDeviceReadiness(code) {
9
+ return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] };
10
+ }
11
+ function values(text, pattern) { return [...text.matchAll(pattern)].map(match => match[1]); }
12
+ function unique(items) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null; }
13
+ export function parseAndroidPower(text) {
14
+ // Later vendor/debug sections can repeat mWakefulness as a numeric enum.
15
+ // Classify only the named current-state section when its boundary is present.
16
+ const main = text.split('Power Manager State:')[1];
17
+ if (main?.includes('Settings and Configuration:'))
18
+ text = main.split('Settings and Configuration:')[0];
19
+ const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm));
20
+ const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm));
21
+ if (!wake || !changing)
22
+ return 'unknown';
23
+ if (changing === 'true')
24
+ return 'transitioning';
25
+ return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown';
26
+ }
27
+ export function parseAndroidLock(text) {
28
+ const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1];
29
+ if (!section)
30
+ return 'unknown';
31
+ const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm));
32
+ return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown';
33
+ }
34
+ export function finishDeviceReadiness(state) {
35
+ if (state.wakefulness === 'asleep')
36
+ state.blockers.push('DEVICE_ASLEEP');
37
+ if (state.wakefulness === 'transitioning')
38
+ state.blockers.push('DEVICE_TRANSITIONING');
39
+ if (state.lock_state === 'locked')
40
+ state.blockers.push('DEVICE_LOCKED');
41
+ state.blockers = [...new Set(state.blockers)];
42
+ state.input_ready = state.blockers.length ? false : null;
43
+ return state;
44
+ }
45
+ export function parseIosLock(text) {
46
+ try {
47
+ const data = JSON.parse(text);
48
+ if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
49
+ data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate')
50
+ return 'unsupported';
51
+ // Successful response semantics require runtime validation before classification.
52
+ }
53
+ catch { /* Non-JSON output supplies no state evidence. */ }
54
+ return 'unknown';
55
+ }
56
+ const probes = new AsyncLocalStorage();
57
+ export function withDeviceReadinessProbe(probe, operation) { return probes.run(probe, operation); }
58
+ const nativeProbe = (command, args, timeout) => execCmd(command, args, { timeout });
59
+ export async function observeDeviceReadiness(device, probe = probes.getStore() ?? nativeProbe) {
60
+ return benchmarkStage('device_readiness_ms', () => observe(device, probe));
61
+ }
62
+ async function observe(device, probe) {
63
+ const state = unknownDeviceReadiness();
64
+ if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id))
65
+ return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
66
+ const paused = pausedReadiness(device.id, device.platform);
67
+ if (paused) {
68
+ state.blockers.push('DEBUGGER_PAUSED');
69
+ state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms });
70
+ return finishDeviceReadiness(state);
71
+ }
72
+ if (device.platform === 'android') {
73
+ await androidProbe(state, device.id, 'power', probe);
74
+ await androidProbe(state, device.id, 'window', probe);
75
+ }
76
+ else {
77
+ state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED');
78
+ try {
79
+ const timeout = remainingBudget(1000);
80
+ // CoreDevice requires a CLI timeout of at least five seconds; the parent
81
+ // subprocess deadline still caps this read at one second.
82
+ const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout);
83
+ remainingBudget();
84
+ state.lock_state = parseIosLock(result.stdout);
85
+ if (state.lock_state === 'unsupported')
86
+ state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() });
87
+ else
88
+ state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE');
89
+ }
90
+ catch {
91
+ state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED');
92
+ }
93
+ }
94
+ return finishDeviceReadiness(state);
95
+ }
96
+ async function androidProbe(state, id, kind, probe) {
97
+ try {
98
+ const timeout = remainingBudget(1000);
99
+ const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout);
100
+ remainingBudget();
101
+ if (result.exitCode !== 0)
102
+ throw new Error('PROBE_FAILED');
103
+ const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout);
104
+ if (kind === 'power')
105
+ state.wakefulness = value;
106
+ else
107
+ state.lock_state = value;
108
+ if (value === 'unknown')
109
+ state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED');
110
+ else
111
+ state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() });
112
+ }
113
+ catch {
114
+ state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED');
115
+ }
116
+ }
@@ -217,11 +217,21 @@ export function refreshJourneyEvidence(steps) {
217
217
  const child = current.children.find(c => c.action?.id === previous?.action_id);
218
218
  if (child && step.result)
219
219
  step.result.evidence = assemble(child);
220
+ else if (previous?.observations && step.result) {
221
+ const ids = new Set(previous.observations.map(o => o.evidence_id));
222
+ step.result.evidence = assemble({ ...current, observations: current.observations.filter(o => ids.has(o.value.evidence_id)) });
223
+ }
220
224
  }
221
225
  }
222
226
  export function completeJourneyVerification(actionId) {
223
227
  const child = context.getStore()?.children.find(c => c.action?.id === actionId);
224
228
  if (child?.action) {
229
+ const parent = context.getStore();
230
+ const completed = child.action.dispatchEnd?.mono;
231
+ if (completed !== undefined) {
232
+ const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action.device));
233
+ child.observations.push(...after);
234
+ }
225
235
  child.verification = stamp();
226
236
  child.action.verification = child.verification;
227
237
  }
@@ -317,3 +327,24 @@ function recordCaptureTime(acquisition, result, start) {
317
327
  acquisition.reused = source < start.wall;
318
328
  }
319
329
  }
330
+ export class JourneyStepError extends Error {
331
+ evidence;
332
+ constructor(evidence, cause) {
333
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
334
+ this.evidence = evidence;
335
+ }
336
+ }
337
+ /** Retain per-step evidence without changing the parent journey's acquisition flow. */
338
+ export async function withJourneyStepEvidence(operation) {
339
+ const current = context.getStore();
340
+ const start = current?.observations.length ?? 0;
341
+ try {
342
+ const result = await operation();
343
+ return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) };
344
+ }
345
+ catch (error) {
346
+ if (current)
347
+ throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error);
348
+ throw error;
349
+ }
350
+ }
@@ -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
+ }