@mmerterden/multi-agent-toolkit-mcp 3.0.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 (41) hide show
  1. package/CHANGELOG.md +871 -0
  2. package/LICENSE +21 -0
  3. package/README.md +358 -0
  4. package/README.tr.md +358 -0
  5. package/index.js +1725 -0
  6. package/package.json +89 -0
  7. package/tools/crash-logs/index.js +29 -0
  8. package/tools/design-check/content-cardinality.js +204 -0
  9. package/tools/design-check/geometry.js +140 -0
  10. package/tools/design-check/index.js +219 -0
  11. package/tools/design-check/mock-detect.js +213 -0
  12. package/tools/design-check/report.js +596 -0
  13. package/tools/design-check/scan.js +91 -0
  14. package/tools/design-check/scenario-inventory.js +598 -0
  15. package/tools/design-check/visual-compare.js +961 -0
  16. package/tools/ios-app-store-audit/context.js +181 -0
  17. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  18. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  19. package/tools/ios-app-store-audit/index.js +164 -0
  20. package/tools/ios-app-store-audit/models.js +57 -0
  21. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  22. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  23. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  24. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  25. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  26. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  27. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  28. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  29. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  30. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  31. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  32. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  33. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  34. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  35. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  36. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  37. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  38. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  39. package/tools/ios-testflight/index.js +489 -0
  40. package/tools/ui-inspect/index.js +57 -0
  41. package/ui-tree-dumper.swift +122 -0
@@ -0,0 +1,489 @@
1
+ /**
2
+ * TestFlight / App Store pre-submission validation.
3
+ *
4
+ * Two capabilities, both thin wrappers over Apple's own tooling:
5
+ *
6
+ * exportIpa() xcodebuild -exportArchive (.xcarchive -> .ipa)
7
+ * validateApp() xcrun altool --validate-app (.ipa -> Apple's verdict)
8
+ *
9
+ * Why this exists next to `ios_app_store_audit`: that tool is a STATIC scanner.
10
+ * It reads the archive on disk and can only find what is visible there. It
11
+ * cannot know whether the bundle ID is registered, whether the profile matches
12
+ * the App Store Connect app record, whether this version+build pair was already
13
+ * used, or whether an entitlement is actually provisioned for the app ID. Only
14
+ * Apple's server can answer those, and `altool --validate-app` is how you ask.
15
+ * The two are complementary, not alternatives - run the static scan first
16
+ * because it is free, then this.
17
+ *
18
+ * Secret handling: altool accepts `-p @keychain:<item>` and `-p @env:<VAR>`
19
+ * indirections. This module uses ONLY those. A password is never placed in an
20
+ * argv element, so it cannot leak through the process list, a crash dump, or an
21
+ * error message that echoes the failing command.
22
+ *
23
+ * @module tools/ios-testflight
24
+ */
25
+
26
+ import { execFile } from "child_process";
27
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
28
+ import { join } from "path";
29
+ import { tmpdir } from "os";
30
+
31
+ /**
32
+ * ITMS error code -> the App Store rule it maps onto.
33
+ *
34
+ * Deliberately partial. Only codes whose guideline mapping is unambiguous are
35
+ * listed; everything else passes Apple's own message through verbatim rather
36
+ * than guessing, because a confidently wrong guideline reference sends the
37
+ * developer to the wrong fix.
38
+ */
39
+ export const ITMS_GUIDELINE_MAP = Object.freeze({
40
+ "ITMS-90683": {
41
+ guideline: "5.1.1 Data Collection and Storage",
42
+ hint: "Info.plist is missing a purpose string for a privacy-sensitive API. Every NS*UsageDescription must state why the app needs that data, in user-facing language.",
43
+ },
44
+ "ITMS-91053": {
45
+ guideline: "5.1.1 / Privacy manifest requirement",
46
+ hint: "PrivacyInfo.xcprivacy is missing a required-reason entry for an API in the required-reason list. Declare the reason code, do not remove the declaration.",
47
+ },
48
+ "ITMS-91061": {
49
+ guideline: "Privacy manifest requirement (third-party SDK)",
50
+ hint: "A bundled SDK on Apple's commonly-used list ships no signature or privacy manifest. Update to a version that includes one.",
51
+ },
52
+ "ITMS-91065": {
53
+ guideline: "Privacy manifest requirement (SDK signature)",
54
+ hint: "A bundled SDK is missing a valid code signature. Update the dependency; you cannot sign someone else's binary for them.",
55
+ },
56
+ "ITMS-90725": {
57
+ guideline: "Minimum SDK requirement",
58
+ hint: "Built against an SDK older than the App Store floor. Rebuild with a current Xcode.",
59
+ },
60
+ "ITMS-90022": {
61
+ guideline: "2.3 Accurate Metadata / asset completeness",
62
+ hint: "A required icon size is missing from the asset catalog.",
63
+ },
64
+ "ITMS-90023": {
65
+ guideline: "2.3 Accurate Metadata / asset completeness",
66
+ hint: "A required icon set is missing from the asset catalog.",
67
+ },
68
+ "ITMS-90717": {
69
+ guideline: "Asset requirements",
70
+ hint: "The App Store icon has an alpha channel. Flatten it: App Store icons must be fully opaque.",
71
+ },
72
+ "ITMS-90174": {
73
+ guideline: "Signing and provisioning",
74
+ hint: "No provisioning profile is embedded. Export with an App Store distribution profile.",
75
+ },
76
+ "ITMS-90283": {
77
+ guideline: "Signing and provisioning",
78
+ hint: "The embedded provisioning profile is invalid for App Store distribution.",
79
+ },
80
+ "ITMS-90046": {
81
+ guideline: "Signing and provisioning",
82
+ hint: "An entitlement in the binary is not permitted by the provisioning profile. Align the entitlements file with what the App ID is provisioned for.",
83
+ },
84
+ "ITMS-90034": {
85
+ guideline: "Signing and provisioning",
86
+ hint: "The binary is not signed, or the signature is invalid.",
87
+ },
88
+ "ITMS-90035": {
89
+ guideline: "Signing and provisioning",
90
+ hint: "Invalid signature. Re-sign with a valid Apple Distribution identity.",
91
+ },
92
+ "ITMS-90809": {
93
+ guideline: "2.5.1 Software Requirements (deprecated API)",
94
+ hint: "The binary references a removed API. Migrate off it; this is a hard rejection, not a warning.",
95
+ },
96
+ });
97
+
98
+ /**
99
+ * Callback execFile wrapped so it resolves instead of throwing: every caller
100
+ * here treats a non-zero exit as data (a log to parse), not an exception. Async
101
+ * because these children run for minutes and a sync wait would block the MCP
102
+ * server's event loop for the duration.
103
+ */
104
+ function execFileAsync(file, argv, opts) {
105
+ return new Promise((resolve) => {
106
+ execFile(file, argv, opts, (err, stdout, stderr) => {
107
+ resolve({ err, stdout: stdout || "", stderr: stderr || "" });
108
+ });
109
+ });
110
+ }
111
+
112
+ /** Where altool searches for `AuthKey_<keyId>.p8` when `--api-key` is used. */
113
+ export const P8_SEARCH_DIRS = Object.freeze([
114
+ "./private_keys",
115
+ "~/private_keys",
116
+ "~/.private_keys",
117
+ "~/.appstoreconnect/private_keys",
118
+ "$API_PRIVATE_KEYS_DIR",
119
+ ]);
120
+
121
+ /**
122
+ * Resolve which authentication tier is usable, without touching any secret.
123
+ *
124
+ * Tier 1 - App Store Connect API key (`--api-key` + `--api-issuer`). Preferred:
125
+ * no 2FA, no interactive prompt, works unattended. Creating one needs
126
+ * an Admin or App Manager role in App Store Connect, which many
127
+ * developers on a corporate team do not have.
128
+ * Tier 2 - Apple ID + app-specific password. Any Apple ID holder can generate
129
+ * one at appleid.apple.com with no elevated role, which makes this the
130
+ * realistic path on a locked-down corporate account. The secret is
131
+ * referenced indirectly (`@keychain:` / `@env:`), never passed by value.
132
+ * Tier 3 - Nothing available. Returns tier `null`. Callers MUST surface this as
133
+ * a skipped gate, never as a pass: "Apple did not object" and "Apple
134
+ * was never asked" are different results.
135
+ *
136
+ * @param {object} opts
137
+ * @param {string} [opts.apiKeyId] ASC API key id (the `<keyId>` in AuthKey_<keyId>.p8)
138
+ * @param {string} [opts.apiIssuerId] ASC issuer id
139
+ * @param {string} [opts.p8Path] explicit path to the .p8, bypassing the search dirs
140
+ * @param {string} [opts.appleId] Apple ID for tier 2
141
+ * @param {string} [opts.keychainItem] keychain item holding the app-specific password
142
+ * @param {string} [opts.passwordEnvVar] env var holding the app-specific password
143
+ * @param {string} [opts.providerPublicId] required when the account has multiple providers
144
+ * @returns {{tier: 1|2|null, method: string, argv: string[], reason?: string, requiresEnv?: string}}
145
+ */
146
+ export function resolveAuth(opts = {}) {
147
+ const {
148
+ apiKeyId,
149
+ apiIssuerId,
150
+ p8Path,
151
+ appleId,
152
+ keychainItem,
153
+ passwordEnvVar,
154
+ providerPublicId,
155
+ } = opts;
156
+
157
+ const provider = providerPublicId ? ["--provider-public-id", providerPublicId] : [];
158
+
159
+ if (apiKeyId && apiIssuerId) {
160
+ const argv = ["--api-key", apiKeyId, "--api-issuer", apiIssuerId];
161
+ if (p8Path) argv.push("--p8-file-path", p8Path);
162
+ return {
163
+ tier: 1,
164
+ method: "asc-api-key",
165
+ argv: [...argv, ...provider],
166
+ };
167
+ }
168
+
169
+ if (appleId && (keychainItem || passwordEnvVar)) {
170
+ // Indirection only. `-p <literal>` would put the password in argv.
171
+ const secretRef = keychainItem ? `@keychain:${keychainItem}` : `@env:${passwordEnvVar}`;
172
+ if (passwordEnvVar && !keychainItem && !process.env[passwordEnvVar]) {
173
+ // Caught here rather than left to altool: `-p @env:MISSING` fails deep
174
+ // inside delivery with an authentication error that reads like a wrong
175
+ // password, sending the caller to rotate a credential that was fine.
176
+ return {
177
+ tier: null,
178
+ method: "none",
179
+ argv: [],
180
+ reason: `password_env_var "${passwordEnvVar}" is not set in this process, so altool would read an empty password`,
181
+ };
182
+ }
183
+ return {
184
+ tier: 2,
185
+ method: keychainItem ? "apple-id-keychain" : "apple-id-env",
186
+ argv: ["-u", appleId, "-p", secretRef, ...provider],
187
+ requiresEnv: keychainItem ? undefined : passwordEnvVar,
188
+ };
189
+ }
190
+
191
+ // A literal password is refused rather than used. Accepting one would put the
192
+ // secret in argv, where it leaks through the process list and through any error
193
+ // that echoes the failing command - and doing it silently is worse, because the
194
+ // caller would have no signal that their credential was exposed.
195
+ if (opts.password) {
196
+ return {
197
+ tier: null,
198
+ method: "none",
199
+ argv: [],
200
+ reason:
201
+ "a literal password was supplied; pass it by reference instead (keychain_item, or password_env_var) so it never reaches the process list. `altool --store-password-in-keychain-item <name> -u <apple-id> -p <password>` creates the keychain item once.",
202
+ };
203
+ }
204
+
205
+ return {
206
+ tier: null,
207
+ method: "none",
208
+ argv: [],
209
+ reason:
210
+ apiKeyId || apiIssuerId
211
+ ? "incomplete API key credentials: both --api-key and --api-issuer are required"
212
+ : appleId
213
+ ? "an Apple ID was supplied with no password reference (keychain item or env var)"
214
+ : "no App Store Connect credentials configured",
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Build the `exportOptions.plist` xcodebuild needs to turn an archive into a
220
+ * distributable .ipa.
221
+ *
222
+ * @param {object} opts
223
+ * @param {string} opts.method e.g. "app-store-connect"
224
+ * @param {string} [opts.teamId]
225
+ * @param {Record<string,string>} [opts.provisioningProfiles] bundleId -> profile name
226
+ * @param {boolean} [opts.uploadSymbols]
227
+ * @param {string} [opts.signingStyle] "automatic" | "manual"
228
+ * @returns {string} plist XML
229
+ */
230
+ export function buildExportOptionsPlist(opts) {
231
+ const {
232
+ method = "app-store-connect",
233
+ teamId,
234
+ provisioningProfiles,
235
+ uploadSymbols = true,
236
+ signingStyle,
237
+ } = opts;
238
+
239
+ const entries = [` <key>method</key>\n <string>${method}</string>`];
240
+ entries.push(
241
+ ` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
242
+ );
243
+ if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
244
+ if (signingStyle) {
245
+ entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
246
+ }
247
+ if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
248
+ const rows = Object.entries(provisioningProfiles)
249
+ .map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
250
+ .join("\n");
251
+ entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
252
+ }
253
+
254
+ return [
255
+ '<?xml version="1.0" encoding="UTF-8"?>',
256
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
257
+ '<plist version="1.0">',
258
+ " <dict>",
259
+ entries.join("\n"),
260
+ " </dict>",
261
+ "</plist>",
262
+ "",
263
+ ].join("\n");
264
+ }
265
+
266
+ /**
267
+ * Export a .xcarchive to a signed .ipa.
268
+ *
269
+ * @param {object} opts
270
+ * @param {string} opts.archivePath
271
+ * @param {string} opts.outputDir
272
+ * @param {string} [opts.method]
273
+ * @param {string} [opts.teamId]
274
+ * @param {Record<string,string>} [opts.provisioningProfiles]
275
+ * @param {string} [opts.signingStyle]
276
+ * @param {boolean} [opts.uploadSymbols]
277
+ * @param {number} [opts.timeoutSec]
278
+ * @param {AbortSignal} [opts.signal]
279
+ * @returns {Promise<{ok: boolean, ipaPath?: string, exportOptionsPath: string, errors: string[], log: string}>}
280
+ */
281
+ export async function exportIpa(opts) {
282
+ const { archivePath, outputDir, timeoutSec = 900, allowProvisioningUpdates = false } = opts;
283
+ if (!archivePath || !existsSync(archivePath)) {
284
+ return { ok: false, errors: [`archive not found: ${archivePath}`], log: "", exportOptionsPath: "" };
285
+ }
286
+
287
+ const plist = buildExportOptionsPlist(opts);
288
+ const workDir = mkdtempSync(join(tmpdir(), "tf-export-"));
289
+ const exportOptionsPath = join(workDir, "exportOptions.plist");
290
+ writeFileSync(exportOptionsPath, plist);
291
+
292
+ const argv = [
293
+ "-exportArchive",
294
+ "-archivePath",
295
+ archivePath,
296
+ "-exportPath",
297
+ outputDir,
298
+ "-exportOptionsPlist",
299
+ exportOptionsPath,
300
+ ];
301
+ // Off by default, and that is deliberate. `-allowProvisioningUpdates` lets
302
+ // xcodebuild register devices and create or modify provisioning profiles in the
303
+ // developer account - a change on Apple's side, made by a tool whose job is to
304
+ // check a build rather than to alter the account it belongs to. Opt in when you
305
+ // want that.
306
+ if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
307
+
308
+ const { err, stdout, stderr } = await execFileAsync("xcodebuild", argv, {
309
+ encoding: "utf-8",
310
+ timeout: timeoutSec * 1000,
311
+ maxBuffer: 64 * 1024 * 1024,
312
+ signal: opts.signal,
313
+ });
314
+ let ok = !err;
315
+ let log = err
316
+ ? `${stdout}\n${stderr}${err.signal === "SIGTERM" ? "\n[TIMEOUT]" : ""}`
317
+ : stdout;
318
+
319
+ const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
320
+ let ipaPath;
321
+ if (existsSync(outputDir)) {
322
+ const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
323
+ if (ipa) ipaPath = join(outputDir, ipa);
324
+ }
325
+ // xcodebuild can exit 0 and still produce nothing useful.
326
+ if (!ipaPath) ok = false;
327
+
328
+ // The plist is consumed by the time xcodebuild returns. Leaving the temp dir
329
+ // behind would accumulate one per export for the life of the machine; the path
330
+ // is still reported for a failed run so the caller can inspect what was sent.
331
+ let exportOptions = exportOptionsPath;
332
+ if (ok) {
333
+ try {
334
+ rmSync(workDir, { recursive: true, force: true });
335
+ exportOptions = null;
336
+ } catch {
337
+ /* leaving a temp dir behind is not worth failing an otherwise good export */
338
+ }
339
+ }
340
+
341
+ return { ok, ipaPath, exportOptionsPath: exportOptions, errors, log };
342
+ }
343
+
344
+ /**
345
+ * Parse altool's JSON output into a flat issue list.
346
+ *
347
+ * altool nests findings differently across versions and error classes
348
+ * (`product-errors`, `errors`, `warnings`), so every known shape is walked and
349
+ * anything unrecognised is preserved as a raw message rather than dropped - a
350
+ * validator that silently loses Apple's objection is worse than no validator.
351
+ *
352
+ * @param {string} stdout
353
+ * @returns {{issues: Array<{code: string|null, message: string, severity: string}>, parsed: boolean}}
354
+ */
355
+ export function parseAltoolOutput(stdout) {
356
+ const issues = [];
357
+ let parsed = false;
358
+
359
+ const pushRaw = (message, severity) => {
360
+ if (!message) return;
361
+ const code = (message.match(/ITMS-\d+/) || [null])[0];
362
+ issues.push({ code, message: String(message).trim(), severity });
363
+ };
364
+
365
+ try {
366
+ const json = JSON.parse(stdout);
367
+ parsed = true;
368
+ for (const key of ["product-errors", "errors"]) {
369
+ for (const e of json[key] || []) {
370
+ pushRaw(e.message || JSON.stringify(e), "error");
371
+ }
372
+ }
373
+ for (const w of json.warnings || []) {
374
+ pushRaw(w.message || JSON.stringify(w), "warning");
375
+ }
376
+ } catch {
377
+ // Not JSON (older altool, or a transport-level failure printed as text).
378
+ for (const line of stdout.split("\n")) {
379
+ if (/ITMS-\d+|error:/i.test(line)) pushRaw(line, "error");
380
+ }
381
+ }
382
+
383
+ return { issues, parsed };
384
+ }
385
+
386
+ /**
387
+ * Ask Apple to validate an .ipa for App Store / TestFlight delivery.
388
+ *
389
+ * @param {object} opts
390
+ * @param {string} opts.ipaPath
391
+ * @param {string} [opts.platform] ios | appletvos | visionos | macos
392
+ * @param {object} opts.auth result of resolveAuth()
393
+ * @param {number} [opts.timeoutSec]
394
+ * @param {AbortSignal} [opts.signal]
395
+ * @returns {Promise<object>} structured verdict; `verdict: "SKIPPED"` when unauthenticated
396
+ */
397
+ export async function validateApp(opts) {
398
+ const { ipaPath, platform = "ios", auth, timeoutSec = 900 } = opts;
399
+
400
+ if (!ipaPath || !existsSync(ipaPath)) {
401
+ return { verdict: "ERROR", error: `ipa not found: ${ipaPath}` };
402
+ }
403
+ if (!auth || auth.tier === null) {
404
+ // Explicitly not a pass. The caller must render this as an unrun gate.
405
+ return {
406
+ tool: "altool --validate-app",
407
+ ipa: ipaPath,
408
+ platform,
409
+ authTier: null,
410
+ authMethod: "none",
411
+ verdict: "SKIPPED",
412
+ skippedReason:
413
+ auth?.reason || "no App Store Connect credentials configured; Apple was never asked",
414
+ issues: [],
415
+ };
416
+ }
417
+
418
+ const argv = [
419
+ "altool",
420
+ "--validate-app",
421
+ ipaPath,
422
+ "-t",
423
+ platform,
424
+ "--output-format",
425
+ "json",
426
+ ...auth.argv,
427
+ ];
428
+
429
+ const started = Date.now();
430
+ const res = await execFileAsync("xcrun", argv, {
431
+ encoding: "utf-8",
432
+ timeout: timeoutSec * 1000,
433
+ maxBuffer: 32 * 1024 * 1024,
434
+ signal: opts.signal,
435
+ });
436
+ const failed = !!res.err;
437
+ const stdout = failed
438
+ ? `${res.stdout}\n${res.stderr}${res.err.signal === "SIGTERM" ? "\n[TIMEOUT]" : ""}`
439
+ : res.stdout;
440
+
441
+ const { issues, parsed } = parseAltoolOutput(stdout);
442
+ const enriched = issues.map((i) => ({
443
+ ...i,
444
+ ...(i.code && ITMS_GUIDELINE_MAP[i.code] ? ITMS_GUIDELINE_MAP[i.code] : {}),
445
+ }));
446
+ const errors = enriched.filter((i) => i.severity === "error");
447
+
448
+ return {
449
+ tool: "altool --validate-app",
450
+ ipa: ipaPath,
451
+ platform,
452
+ authTier: auth.tier,
453
+ authMethod: auth.method,
454
+ // A non-zero exit with no parsed issue is still a failure: the run did not
455
+ // produce Apple's approval, and treating "unparseable" as success is how a
456
+ // broken gate reports green.
457
+ verdict: failed || errors.length > 0 ? "FAIL" : "PASS",
458
+ outputParsed: parsed,
459
+ durationMs: Date.now() - started,
460
+ summary: {
461
+ errors: errors.length,
462
+ warnings: enriched.length - errors.length,
463
+ mappedToGuideline: enriched.filter((i) => i.guideline).length,
464
+ },
465
+ issues: enriched,
466
+ rawOutput: stdout.slice(-8000),
467
+ };
468
+ }
469
+
470
+ /**
471
+ * List the providers (teams) the credentials can deliver for.
472
+ *
473
+ * Useful as a pre-flight: a corporate Apple ID that belongs to several teams
474
+ * needs `--provider-public-id`, and altool's failure without it is opaque.
475
+ *
476
+ * @param {object} auth result of resolveAuth()
477
+ * @param {AbortSignal} [signal]
478
+ * @returns {Promise<{ok: boolean, output: string}>}
479
+ */
480
+ export async function listProviders(auth, signal) {
481
+ if (!auth || auth.tier === null) return { ok: false, output: "no credentials configured" };
482
+ const { err, stdout, stderr } = await execFileAsync(
483
+ "xcrun",
484
+ ["altool", "--list-providers", "--output-format", "json", ...auth.argv],
485
+ { encoding: "utf-8", timeout: 120000, signal },
486
+ );
487
+ if (err) return { ok: false, output: `${stdout}\n${stderr}`.trim() };
488
+ return { ok: true, output: stdout };
489
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Compact views over a uiautomator XML dump.
3
+ *
4
+ * The raw dump is unbounded - a busy screen easily exceeds 100 KB - and most
5
+ * callers only want the elements they can act on. This extracts those with
6
+ * the coordinates a tap needs, so the tool result costs tokens proportional
7
+ * to the actionable surface, not to the layout tree.
8
+ *
9
+ * @module tools/ui-inspect
10
+ */
11
+
12
+ const NODE_RE = /<node[^>]*>/g;
13
+ const BOUNDS_RE = /bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/;
14
+
15
+ function attr(node, name) {
16
+ const m = node.match(new RegExp(`${name}="([^"]*)"`));
17
+ return m ? m[1] : "";
18
+ }
19
+
20
+ /**
21
+ * Pull the interactive elements out of a uiautomator XML dump.
22
+ *
23
+ * Interactive means clickable, long-clickable, checkable or scrollable - the
24
+ * set of things an agent can drive. Center coordinates are precomputed
25
+ * because that is what `input tap` takes.
26
+ *
27
+ * @param {string} xml raw uiautomator dump
28
+ * @returns {Array<{class: string, text: string, resource_id: string, content_desc: string, center: {x: number, y: number}, bounds: string}>}
29
+ */
30
+ export function interactiveElements(xml) {
31
+ const elements = [];
32
+ let match;
33
+ NODE_RE.lastIndex = 0;
34
+ while ((match = NODE_RE.exec(String(xml))) !== null) {
35
+ const node = match[0];
36
+ const interactive =
37
+ node.includes('clickable="true"') ||
38
+ node.includes('long-clickable="true"') ||
39
+ node.includes('checkable="true"') ||
40
+ node.includes('scrollable="true"');
41
+ if (!interactive) continue;
42
+ const b = node.match(BOUNDS_RE);
43
+ const entry = {
44
+ class: attr(node, "class"),
45
+ text: attr(node, "text"),
46
+ resource_id: attr(node, "resource-id"),
47
+ content_desc: attr(node, "content-desc"),
48
+ };
49
+ if (b) {
50
+ const [x1, y1, x2, y2] = [Number(b[1]), Number(b[2]), Number(b[3]), Number(b[4])];
51
+ entry.center = { x: Math.round((x1 + x2) / 2), y: Math.round((y1 + y2) / 2) };
52
+ entry.bounds = `[${x1},${y1}][${x2},${y2}]`;
53
+ }
54
+ elements.push(entry);
55
+ }
56
+ return elements;
57
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/swift
2
+ // ui-tree-dumper.swift
3
+ // Reads the iOS Simulator accessibility tree via macOS AX APIs (host-side)
4
+ // Usage: swift ui-tree-dumper.swift [max-depth]
5
+ // Output: JSON array of accessibility elements
6
+
7
+ import Cocoa
8
+ import Foundation
9
+
10
+ struct AXNode: Codable {
11
+ let role: String
12
+ let title: String?
13
+ let value: String?
14
+ let description: String?
15
+ let identifier: String?
16
+ let frame: [String: Double]
17
+ let enabled: Bool
18
+ let focused: Bool
19
+ let children: [AXNode]
20
+ }
21
+
22
+ func getAXValue(_ element: AXUIElement, _ attr: String) -> AnyObject? {
23
+ var value: AnyObject?
24
+ AXUIElementCopyAttributeValue(element, attr as CFString, &value)
25
+ return value
26
+ }
27
+
28
+ func getString(_ element: AXUIElement, _ attr: String) -> String? {
29
+ return getAXValue(element, attr) as? String
30
+ }
31
+
32
+ func getBool(_ element: AXUIElement, _ attr: String) -> Bool {
33
+ return (getAXValue(element, attr) as? Bool) ?? false
34
+ }
35
+
36
+ func getFrame(_ element: AXUIElement) -> [String: Double] {
37
+ var pos = CGPoint.zero
38
+ var size = CGSize.zero
39
+ var posValue: AnyObject?
40
+ var sizeValue: AnyObject?
41
+ AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &posValue)
42
+ AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue)
43
+ if let pv = posValue { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) }
44
+ if let sv = sizeValue { AXValueGetValue(sv as! AXValue, .cgSize, &size) }
45
+ return ["x": Double(pos.x), "y": Double(pos.y), "w": Double(size.width), "h": Double(size.height)]
46
+ }
47
+
48
+ func dumpElement(_ element: AXUIElement, depth: Int, maxDepth: Int) -> AXNode? {
49
+ guard depth < maxDepth else { return nil }
50
+
51
+ let role = getString(element, kAXRoleAttribute) ?? "unknown"
52
+ let title = getString(element, kAXTitleAttribute)
53
+ let value = getString(element, kAXValueAttribute)
54
+ let desc = getString(element, kAXDescriptionAttribute)
55
+ let identifier = getString(element, kAXIdentifierAttribute)
56
+ let enabled = getBool(element, kAXEnabledAttribute)
57
+ let focused = getBool(element, kAXFocusedAttribute)
58
+ let frame = getFrame(element)
59
+
60
+ var children: [AXNode] = []
61
+ var childrenRef: AnyObject?
62
+ AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
63
+ if let kids = childrenRef as? [AXUIElement] {
64
+ for kid in kids {
65
+ if let child = dumpElement(kid, depth: depth + 1, maxDepth: maxDepth) {
66
+ children.append(child)
67
+ }
68
+ }
69
+ }
70
+
71
+ // Skip empty nodes with no useful info
72
+ if title == nil && value == nil && desc == nil && identifier == nil && children.isEmpty && role == "AXGroup" {
73
+ return nil
74
+ }
75
+
76
+ return AXNode(
77
+ role: role,
78
+ title: title,
79
+ value: value,
80
+ description: desc,
81
+ identifier: identifier,
82
+ frame: frame,
83
+ enabled: enabled,
84
+ focused: focused,
85
+ children: children
86
+ )
87
+ }
88
+
89
+ func findSimulatorWindow() -> AXUIElement? {
90
+ let apps = NSWorkspace.shared.runningApplications
91
+ for app in apps {
92
+ if app.bundleIdentifier == "com.apple.iphonesimulator" {
93
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
94
+ var windows: AnyObject?
95
+ AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windows)
96
+ if let wins = windows as? [AXUIElement], let first = wins.first {
97
+ return first
98
+ }
99
+ }
100
+ }
101
+ return nil
102
+ }
103
+
104
+ // Main
105
+ let maxDepth = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 10 : 10
106
+
107
+ guard let simWindow = findSimulatorWindow() else {
108
+ let error = ["error": "Simulator not running or no window found"]
109
+ let data = try! JSONSerialization.data(withJSONObject: error)
110
+ FileHandle.standardOutput.write(data)
111
+ exit(1)
112
+ }
113
+
114
+ if let tree = dumpElement(simWindow, depth: 0, maxDepth: maxDepth) {
115
+ let encoder = JSONEncoder()
116
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
117
+ if let data = try? encoder.encode(tree) {
118
+ FileHandle.standardOutput.write(data)
119
+ }
120
+ } else {
121
+ print("{\"error\": \"Could not parse accessibility tree\"}")
122
+ }