@mmerterden/dev-toolkit-mcp 2.24.1

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 (38) hide show
  1. package/CHANGELOG.md +708 -0
  2. package/LICENSE +21 -0
  3. package/README.md +387 -0
  4. package/index.js +1277 -0
  5. package/package.json +80 -0
  6. package/tools/design-check/content-cardinality.js +204 -0
  7. package/tools/design-check/geometry.js +140 -0
  8. package/tools/design-check/index.js +213 -0
  9. package/tools/design-check/mock-detect.js +213 -0
  10. package/tools/design-check/report.js +590 -0
  11. package/tools/design-check/scan.js +91 -0
  12. package/tools/design-check/scenario-inventory.js +598 -0
  13. package/tools/design-check/visual-compare.js +961 -0
  14. package/tools/ios-app-store-audit/context.js +180 -0
  15. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  16. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  17. package/tools/ios-app-store-audit/index.js +164 -0
  18. package/tools/ios-app-store-audit/models.js +47 -0
  19. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  20. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  21. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  22. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  23. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  24. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  25. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  26. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  27. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  28. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  29. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  30. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  31. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  32. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  33. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  34. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  35. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  36. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  37. package/tools/ios-testflight/index.js +479 -0
  38. package/ui-tree-dumper.swift +122 -0
@@ -0,0 +1,479 @@
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 { execFileSync } 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
+ /** Where altool searches for `AuthKey_<keyId>.p8` when `--api-key` is used. */
99
+ export const P8_SEARCH_DIRS = Object.freeze([
100
+ "./private_keys",
101
+ "~/private_keys",
102
+ "~/.private_keys",
103
+ "~/.appstoreconnect/private_keys",
104
+ "$API_PRIVATE_KEYS_DIR",
105
+ ]);
106
+
107
+ /**
108
+ * Resolve which authentication tier is usable, without touching any secret.
109
+ *
110
+ * Tier 1 - App Store Connect API key (`--api-key` + `--api-issuer`). Preferred:
111
+ * no 2FA, no interactive prompt, works unattended. Creating one needs
112
+ * an Admin or App Manager role in App Store Connect, which many
113
+ * developers on a corporate team do not have.
114
+ * Tier 2 - Apple ID + app-specific password. Any Apple ID holder can generate
115
+ * one at appleid.apple.com with no elevated role, which makes this the
116
+ * realistic path on a locked-down corporate account. The secret is
117
+ * referenced indirectly (`@keychain:` / `@env:`), never passed by value.
118
+ * Tier 3 - Nothing available. Returns tier `null`. Callers MUST surface this as
119
+ * a skipped gate, never as a pass: "Apple did not object" and "Apple
120
+ * was never asked" are different results.
121
+ *
122
+ * @param {object} opts
123
+ * @param {string} [opts.apiKeyId] ASC API key id (the `<keyId>` in AuthKey_<keyId>.p8)
124
+ * @param {string} [opts.apiIssuerId] ASC issuer id
125
+ * @param {string} [opts.p8Path] explicit path to the .p8, bypassing the search dirs
126
+ * @param {string} [opts.appleId] Apple ID for tier 2
127
+ * @param {string} [opts.keychainItem] keychain item holding the app-specific password
128
+ * @param {string} [opts.passwordEnvVar] env var holding the app-specific password
129
+ * @param {string} [opts.providerPublicId] required when the account has multiple providers
130
+ * @returns {{tier: 1|2|null, method: string, argv: string[], reason?: string, requiresEnv?: string}}
131
+ */
132
+ export function resolveAuth(opts = {}) {
133
+ const {
134
+ apiKeyId,
135
+ apiIssuerId,
136
+ p8Path,
137
+ appleId,
138
+ keychainItem,
139
+ passwordEnvVar,
140
+ providerPublicId,
141
+ } = opts;
142
+
143
+ const provider = providerPublicId ? ["--provider-public-id", providerPublicId] : [];
144
+
145
+ if (apiKeyId && apiIssuerId) {
146
+ const argv = ["--api-key", apiKeyId, "--api-issuer", apiIssuerId];
147
+ if (p8Path) argv.push("--p8-file-path", p8Path);
148
+ return {
149
+ tier: 1,
150
+ method: "asc-api-key",
151
+ argv: [...argv, ...provider],
152
+ };
153
+ }
154
+
155
+ if (appleId && (keychainItem || passwordEnvVar)) {
156
+ // Indirection only. `-p <literal>` would put the password in argv.
157
+ const secretRef = keychainItem ? `@keychain:${keychainItem}` : `@env:${passwordEnvVar}`;
158
+ if (passwordEnvVar && !keychainItem && !process.env[passwordEnvVar]) {
159
+ // Caught here rather than left to altool: `-p @env:MISSING` fails deep
160
+ // inside delivery with an authentication error that reads like a wrong
161
+ // password, sending the caller to rotate a credential that was fine.
162
+ return {
163
+ tier: null,
164
+ method: "none",
165
+ argv: [],
166
+ reason: `password_env_var "${passwordEnvVar}" is not set in this process, so altool would read an empty password`,
167
+ };
168
+ }
169
+ return {
170
+ tier: 2,
171
+ method: keychainItem ? "apple-id-keychain" : "apple-id-env",
172
+ argv: ["-u", appleId, "-p", secretRef, ...provider],
173
+ requiresEnv: keychainItem ? undefined : passwordEnvVar,
174
+ };
175
+ }
176
+
177
+ // A literal password is refused rather than used. Accepting one would put the
178
+ // secret in argv, where it leaks through the process list and through any error
179
+ // that echoes the failing command - and doing it silently is worse, because the
180
+ // caller would have no signal that their credential was exposed.
181
+ if (opts.password) {
182
+ return {
183
+ tier: null,
184
+ method: "none",
185
+ argv: [],
186
+ reason:
187
+ "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.",
188
+ };
189
+ }
190
+
191
+ return {
192
+ tier: null,
193
+ method: "none",
194
+ argv: [],
195
+ reason:
196
+ apiKeyId || apiIssuerId
197
+ ? "incomplete API key credentials: both --api-key and --api-issuer are required"
198
+ : appleId
199
+ ? "an Apple ID was supplied with no password reference (keychain item or env var)"
200
+ : "no App Store Connect credentials configured",
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Build the `exportOptions.plist` xcodebuild needs to turn an archive into a
206
+ * distributable .ipa.
207
+ *
208
+ * @param {object} opts
209
+ * @param {string} opts.method e.g. "app-store-connect"
210
+ * @param {string} [opts.teamId]
211
+ * @param {Record<string,string>} [opts.provisioningProfiles] bundleId -> profile name
212
+ * @param {boolean} [opts.uploadSymbols]
213
+ * @param {string} [opts.signingStyle] "automatic" | "manual"
214
+ * @returns {string} plist XML
215
+ */
216
+ export function buildExportOptionsPlist(opts) {
217
+ const {
218
+ method = "app-store-connect",
219
+ teamId,
220
+ provisioningProfiles,
221
+ uploadSymbols = true,
222
+ signingStyle,
223
+ } = opts;
224
+
225
+ const entries = [` <key>method</key>\n <string>${method}</string>`];
226
+ entries.push(
227
+ ` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
228
+ );
229
+ if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
230
+ if (signingStyle) {
231
+ entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
232
+ }
233
+ if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
234
+ const rows = Object.entries(provisioningProfiles)
235
+ .map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
236
+ .join("\n");
237
+ entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
238
+ }
239
+
240
+ return [
241
+ '<?xml version="1.0" encoding="UTF-8"?>',
242
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
243
+ '<plist version="1.0">',
244
+ " <dict>",
245
+ entries.join("\n"),
246
+ " </dict>",
247
+ "</plist>",
248
+ "",
249
+ ].join("\n");
250
+ }
251
+
252
+ /**
253
+ * Export a .xcarchive to a signed .ipa.
254
+ *
255
+ * @param {object} opts
256
+ * @param {string} opts.archivePath
257
+ * @param {string} opts.outputDir
258
+ * @param {string} [opts.method]
259
+ * @param {string} [opts.teamId]
260
+ * @param {Record<string,string>} [opts.provisioningProfiles]
261
+ * @param {string} [opts.signingStyle]
262
+ * @param {boolean} [opts.uploadSymbols]
263
+ * @param {number} [opts.timeoutSec]
264
+ * @returns {{ok: boolean, ipaPath?: string, exportOptionsPath: string, errors: string[], log: string}}
265
+ */
266
+ export function exportIpa(opts) {
267
+ const { archivePath, outputDir, timeoutSec = 900, allowProvisioningUpdates = false } = opts;
268
+ if (!archivePath || !existsSync(archivePath)) {
269
+ return { ok: false, errors: [`archive not found: ${archivePath}`], log: "", exportOptionsPath: "" };
270
+ }
271
+
272
+ const plist = buildExportOptionsPlist(opts);
273
+ const workDir = mkdtempSync(join(tmpdir(), "tf-export-"));
274
+ const exportOptionsPath = join(workDir, "exportOptions.plist");
275
+ writeFileSync(exportOptionsPath, plist);
276
+
277
+ const argv = [
278
+ "-exportArchive",
279
+ "-archivePath",
280
+ archivePath,
281
+ "-exportPath",
282
+ outputDir,
283
+ "-exportOptionsPlist",
284
+ exportOptionsPath,
285
+ ];
286
+ // Off by default, and that is deliberate. `-allowProvisioningUpdates` lets
287
+ // xcodebuild register devices and create or modify provisioning profiles in the
288
+ // developer account - a change on Apple's side, made by a tool whose job is to
289
+ // check a build rather than to alter the account it belongs to. Opt in when you
290
+ // want that.
291
+ if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
292
+
293
+ let log = "";
294
+ let ok = true;
295
+ try {
296
+ log = execFileSync("xcodebuild", argv, {
297
+ encoding: "utf-8",
298
+ timeout: timeoutSec * 1000,
299
+ maxBuffer: 64 * 1024 * 1024,
300
+ });
301
+ } catch (e) {
302
+ ok = false;
303
+ log = `${e.stdout || ""}\n${e.stderr || ""}${e.signal === "SIGTERM" ? "\n[TIMEOUT]" : ""}`;
304
+ }
305
+
306
+ const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
307
+ let ipaPath;
308
+ if (existsSync(outputDir)) {
309
+ const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
310
+ if (ipa) ipaPath = join(outputDir, ipa);
311
+ }
312
+ // xcodebuild can exit 0 and still produce nothing useful.
313
+ if (!ipaPath) ok = false;
314
+
315
+ // The plist is consumed by the time xcodebuild returns. Leaving the temp dir
316
+ // behind would accumulate one per export for the life of the machine; the path
317
+ // is still reported for a failed run so the caller can inspect what was sent.
318
+ let exportOptions = exportOptionsPath;
319
+ if (ok) {
320
+ try {
321
+ rmSync(workDir, { recursive: true, force: true });
322
+ exportOptions = null;
323
+ } catch {
324
+ /* leaving a temp dir behind is not worth failing an otherwise good export */
325
+ }
326
+ }
327
+
328
+ return { ok, ipaPath, exportOptionsPath: exportOptions, errors, log };
329
+ }
330
+
331
+ /**
332
+ * Parse altool's JSON output into a flat issue list.
333
+ *
334
+ * altool nests findings differently across versions and error classes
335
+ * (`product-errors`, `errors`, `warnings`), so every known shape is walked and
336
+ * anything unrecognised is preserved as a raw message rather than dropped - a
337
+ * validator that silently loses Apple's objection is worse than no validator.
338
+ *
339
+ * @param {string} stdout
340
+ * @returns {{issues: Array<{code: string|null, message: string, severity: string}>, parsed: boolean}}
341
+ */
342
+ export function parseAltoolOutput(stdout) {
343
+ const issues = [];
344
+ let parsed = false;
345
+
346
+ const pushRaw = (message, severity) => {
347
+ if (!message) return;
348
+ const code = (message.match(/ITMS-\d+/) || [null])[0];
349
+ issues.push({ code, message: String(message).trim(), severity });
350
+ };
351
+
352
+ try {
353
+ const json = JSON.parse(stdout);
354
+ parsed = true;
355
+ for (const key of ["product-errors", "errors"]) {
356
+ for (const e of json[key] || []) {
357
+ pushRaw(e.message || JSON.stringify(e), "error");
358
+ }
359
+ }
360
+ for (const w of json.warnings || []) {
361
+ pushRaw(w.message || JSON.stringify(w), "warning");
362
+ }
363
+ } catch {
364
+ // Not JSON (older altool, or a transport-level failure printed as text).
365
+ for (const line of stdout.split("\n")) {
366
+ if (/ITMS-\d+|error:/i.test(line)) pushRaw(line, "error");
367
+ }
368
+ }
369
+
370
+ return { issues, parsed };
371
+ }
372
+
373
+ /**
374
+ * Ask Apple to validate an .ipa for App Store / TestFlight delivery.
375
+ *
376
+ * @param {object} opts
377
+ * @param {string} opts.ipaPath
378
+ * @param {string} [opts.platform] ios | appletvos | visionos | macos
379
+ * @param {object} opts.auth result of resolveAuth()
380
+ * @param {number} [opts.timeoutSec]
381
+ * @returns {object} structured verdict; `verdict: "SKIPPED"` when unauthenticated
382
+ */
383
+ export function validateApp(opts) {
384
+ const { ipaPath, platform = "ios", auth, timeoutSec = 900 } = opts;
385
+
386
+ if (!ipaPath || !existsSync(ipaPath)) {
387
+ return { verdict: "ERROR", error: `ipa not found: ${ipaPath}` };
388
+ }
389
+ if (!auth || auth.tier === null) {
390
+ // Explicitly not a pass. The caller must render this as an unrun gate.
391
+ return {
392
+ tool: "altool --validate-app",
393
+ ipa: ipaPath,
394
+ platform,
395
+ authTier: null,
396
+ authMethod: "none",
397
+ verdict: "SKIPPED",
398
+ skippedReason:
399
+ auth?.reason || "no App Store Connect credentials configured; Apple was never asked",
400
+ issues: [],
401
+ };
402
+ }
403
+
404
+ const argv = [
405
+ "altool",
406
+ "--validate-app",
407
+ ipaPath,
408
+ "-t",
409
+ platform,
410
+ "--output-format",
411
+ "json",
412
+ ...auth.argv,
413
+ ];
414
+
415
+ const started = Date.now();
416
+ let stdout = "";
417
+ let failed = false;
418
+ try {
419
+ stdout = execFileSync("xcrun", argv, {
420
+ encoding: "utf-8",
421
+ timeout: timeoutSec * 1000,
422
+ maxBuffer: 32 * 1024 * 1024,
423
+ });
424
+ } catch (e) {
425
+ failed = true;
426
+ stdout = `${e.stdout || ""}\n${e.stderr || ""}${e.signal === "SIGTERM" ? "\n[TIMEOUT]" : ""}`;
427
+ }
428
+
429
+ const { issues, parsed } = parseAltoolOutput(stdout);
430
+ const enriched = issues.map((i) => ({
431
+ ...i,
432
+ ...(i.code && ITMS_GUIDELINE_MAP[i.code] ? ITMS_GUIDELINE_MAP[i.code] : {}),
433
+ }));
434
+ const errors = enriched.filter((i) => i.severity === "error");
435
+
436
+ return {
437
+ tool: "altool --validate-app",
438
+ ipa: ipaPath,
439
+ platform,
440
+ authTier: auth.tier,
441
+ authMethod: auth.method,
442
+ // A non-zero exit with no parsed issue is still a failure: the run did not
443
+ // produce Apple's approval, and treating "unparseable" as success is how a
444
+ // broken gate reports green.
445
+ verdict: failed || errors.length > 0 ? "FAIL" : "PASS",
446
+ outputParsed: parsed,
447
+ durationMs: Date.now() - started,
448
+ summary: {
449
+ errors: errors.length,
450
+ warnings: enriched.length - errors.length,
451
+ mappedToGuideline: enriched.filter((i) => i.guideline).length,
452
+ },
453
+ issues: enriched,
454
+ rawOutput: stdout.slice(-8000),
455
+ };
456
+ }
457
+
458
+ /**
459
+ * List the providers (teams) the credentials can deliver for.
460
+ *
461
+ * Useful as a pre-flight: a corporate Apple ID that belongs to several teams
462
+ * needs `--provider-public-id`, and altool's failure without it is opaque.
463
+ *
464
+ * @param {object} auth result of resolveAuth()
465
+ * @returns {{ok: boolean, output: string}}
466
+ */
467
+ export function listProviders(auth) {
468
+ if (!auth || auth.tier === null) return { ok: false, output: "no credentials configured" };
469
+ try {
470
+ const out = execFileSync(
471
+ "xcrun",
472
+ ["altool", "--list-providers", "--output-format", "json", ...auth.argv],
473
+ { encoding: "utf-8", timeout: 120000 },
474
+ );
475
+ return { ok: true, output: out };
476
+ } catch (e) {
477
+ return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}`.trim() };
478
+ }
479
+ }
@@ -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
+ }