@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.
- package/CHANGELOG.md +708 -0
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/index.js +1277 -0
- package/package.json +80 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +213 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +590 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +180 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +47 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +479 -0
- 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
|
+
}
|