@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.9.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.
- package/CHANGELOG.md +106 -0
- package/README.md +7 -0
- package/README.tr.md +7 -0
- package/index.js +309 -84
- package/package.json +1 -1
- package/tools/design-check/index.js +3 -2
- package/tools/design-check/report.js +15 -5
- package/tools/ios-app-store-audit/index.js +16 -2
- package/tools/ios-testflight/index.js +54 -11
- package/tools/memory/index.js +18 -3
- package/tools/offload/index.js +39 -13
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,112 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## 3.9.0
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **Inline screenshots are compact.** `ios_screenshot` and `android_screenshot`
|
|
23
|
+
without `path` returned the raw PNG as base64: on a 3x simulator that is
|
|
24
|
+
2.9 MB per capture, 3.86 million characters on the wire, and the model reads
|
|
25
|
+
every one of them. That was the sluggishness people felt in an agent loop,
|
|
26
|
+
not the capture itself (0.5 s). The inline result is now an 800 px JPEG at
|
|
27
|
+
quality 80 (63 KB, 84 K characters), resized through `sips` in 0.1 s; the
|
|
28
|
+
full-resolution PNG still lands on disk and its path is in the text. New
|
|
29
|
+
optional inputs `max_width`, `format` and `quality` are the escape hatch
|
|
30
|
+
(`format: "png"` with `max_width: 0` returns the original bytes). `path`
|
|
31
|
+
behaviour is unchanged: full-resolution PNG, nothing inline, which is what
|
|
32
|
+
design-check and visual diff read.
|
|
33
|
+
- **The UI-tree dumper runs compiled.** `swift ui-tree-dumper.swift` was
|
|
34
|
+
re-interpreted on every call, about a second each. It is now compiled once
|
|
35
|
+
with `swiftc -O` into the user cache, keyed by the source hash and the
|
|
36
|
+
toolchain version, and runs in 55 ms. No `swiftc`, or a failed compile, falls
|
|
37
|
+
back to the interpreted script and stays there for the process lifetime.
|
|
38
|
+
`ios_get_ui_tree`, `ios_accessibility_audit` and `design_ui_geometry` share
|
|
39
|
+
the path.
|
|
40
|
+
|
|
41
|
+
### Note
|
|
42
|
+
|
|
43
|
+
The README gained a Performance section: read the tree before taking a
|
|
44
|
+
picture, batch a scripted sequence through `agent_run_steps`, write bulk
|
|
45
|
+
captures to files and look only at the ones that changed. The pipeline's
|
|
46
|
+
`/multi-agent:test` flow (16.19.0) now does exactly that.
|
|
47
|
+
|
|
48
|
+
## 3.8.0
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`ios_swipe` declares `duration_ms`.** The handler had read it since the
|
|
53
|
+
tool existed; the schema never said so, so no host ever offered it.
|
|
54
|
+
- **Gate 9c and gate 14b.** 9c validates every probed tool's structured result
|
|
55
|
+
against its own declared `outputSchema` with ajv. 14b scans every shell
|
|
56
|
+
template for a `${args.x}` interpolation that no sanitizer wraps, the shape
|
|
57
|
+
gate 14 could not see because it only knew the double-quoted form.
|
|
58
|
+
- **The work directory is pruned.** `.xcresult` bundles, build logs, push
|
|
59
|
+
payloads and audit dumps accumulated under the screenshot directory with
|
|
60
|
+
nothing to remove them. It now keeps the newest 200 entries for 7 days, the
|
|
61
|
+
offload directory's own rule, and never removes a path a caller was just
|
|
62
|
+
promised.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **`agent_run_steps` skipped schema validation.** Batched steps went straight
|
|
67
|
+
to the handlers, so an enum or type guard that lived only in the schema was
|
|
68
|
+
void inside a batch. Every step is validated now and a failed validation is a
|
|
69
|
+
step error. `ios_get_app_container` also passes its `container` value through
|
|
70
|
+
`token()`, so the enum is not the only thing standing between an argument and
|
|
71
|
+
the shell.
|
|
72
|
+
- **`android_type_text` and `android_open_url` were command injection on the
|
|
73
|
+
device.** `shq()` protected the host shell, and `adb shell` hands the string
|
|
74
|
+
to the device shell unquoted, where it is parsed again. Both are quoted twice
|
|
75
|
+
now; a test evaluates the composed argument through `/bin/sh` and checks the
|
|
76
|
+
original bytes come back.
|
|
77
|
+
- **`android_meminfo mode=diff` always threw.** The snapshot emitted `pss_kb`
|
|
78
|
+
and the diff read `pss`, so the tool's own output was never a valid baseline.
|
|
79
|
+
The diff accepts both shapes.
|
|
80
|
+
- **Three `outputSchema`s contradicted their payloads,** which a conforming
|
|
81
|
+
client rejects: audit findings carry `status: "info"`, `ios_visual_diff`
|
|
82
|
+
returns `diff_image: null` on a clean compare, `android_launch_time` returns
|
|
83
|
+
`cold_start: null` below Android 10. The schemas now allow what the handlers
|
|
84
|
+
emit, and gate 9c keeps them honest.
|
|
85
|
+
- **`ios_app_store_audit` with an unknown rule id reported PASS** after running
|
|
86
|
+
nothing. Unknown ids are an error that lists the valid ones, and an empty
|
|
87
|
+
selection is never a pass.
|
|
88
|
+
- **`ios_accessibility_audit_deep` and `ios_leaks` ran through `execSync`**
|
|
89
|
+
with the default 1 MB buffer, so a normal `xcodebuild test` log killed the
|
|
90
|
+
child and the tool blamed the build; the event loop was blocked for the whole
|
|
91
|
+
run. Both use the async spawn path with a 64 MB cap and a timeout.
|
|
92
|
+
- **`android_apk_audit`'s `aapt2 || aapt` fallback never fired,** because a
|
|
93
|
+
failed `run()` returns a truthy error string; with aapt2 absent the report
|
|
94
|
+
said "Not debuggable - OK" and on Linux `apk_size` was `NaN MB` with status
|
|
95
|
+
pass. Failures are detected, the missing-tool branch is reachable, size comes
|
|
96
|
+
from `statSync`.
|
|
97
|
+
- **Capture tools returned a stale file as a fresh capture.** `ios_screenshot`,
|
|
98
|
+
`android_screenshot` and `android_get_ui_tree` decided success by whether the
|
|
99
|
+
path existed, so a second run against a dropped device returned the previous
|
|
100
|
+
image with the real error replaced by a generic one. The subprocess result is
|
|
101
|
+
checked first and a pre-existing file is discarded before capturing.
|
|
102
|
+
- **`ios_set_locale` reported success unconditionally.** Each of its three
|
|
103
|
+
steps is checked.
|
|
104
|
+
- **`ios_xcodebuild action:"test"` used the generic simulator destination,**
|
|
105
|
+
which xcodebuild refuses for testing. Test defaults to the booted simulator
|
|
106
|
+
or fails with a clear message.
|
|
107
|
+
- **`android_launch_time` and `android_launch_app` disagreed on `activity`.**
|
|
108
|
+
Both build the component the same way and describe the field identically.
|
|
109
|
+
- **Pure-JS iOS tools were refused without Xcode.** `ios_visual_diff`,
|
|
110
|
+
`ios_list_crashes` and `ios_xcresult` need no xcrun and run on a Linux box.
|
|
111
|
+
- **`design_report` leaked a Chromium on a failed PDF render.** The browser is
|
|
112
|
+
closed in `finally`.
|
|
113
|
+
- **`ios_archive_audit` reported an unsigned bundle as signed,** because the
|
|
114
|
+
`codesign` failure text never reached the check; and its size used the
|
|
115
|
+
macOS-only `stat -f`.
|
|
116
|
+
- **Smaller ones.** `ios_leaks` accepts a numeric-string pid and matches the
|
|
117
|
+
bundle id as a whole token instead of a regex prefix; the UI-tree dumper's
|
|
118
|
+
remedy text reaches the caller; `ensureBrowser` closes a half-launched browser
|
|
119
|
+
and honours a changed engine; `exportIpa` picks the export it just made rather
|
|
120
|
+
than the first `.ipa` in the directory, and the export options plist is
|
|
121
|
+
XML-escaped; `agent_run_steps` declares the `result` / `error` fields it
|
|
122
|
+
emits.
|
|
123
|
+
|
|
18
124
|
## 3.7.1
|
|
19
125
|
|
|
20
126
|
### Fixed
|
package/README.md
CHANGED
|
@@ -293,6 +293,13 @@ Mock-mode vs Figma design audit. Generic and platform-agnostic - the heavy orche
|
|
|
293
293
|
- **App Store screenshots** - Clean status bar + automated navigation
|
|
294
294
|
- **Regression testing** - Tap through flows, verify behavior
|
|
295
295
|
|
|
296
|
+
### Performance
|
|
297
|
+
|
|
298
|
+
- **Inline captures are small.** `ios_screenshot` / `android_screenshot` without `path` return an 800 px JPEG, about 60 KB instead of the ~3 MB full-resolution PNG a simulator produces (which is ~4 MB as base64 on every capture). `max_width`, `format` and `quality` tune it; `format: "png"` with `max_width: 0` returns the original bytes. The full-resolution PNG stays on disk, and `path` writes it where you say.
|
|
299
|
+
- **The iOS UI-tree dumper is compiled once.** `ui-tree-dumper.swift` is built with `swiftc -O` on first use and cached per source and toolchain, so a call drops from ~1 s to ~0.05 s. Without `swiftc` it runs interpreted as before.
|
|
300
|
+
- **Batch a scripted sequence.** `agent_run_steps` runs tap, type, wait and screenshot steps in one round trip instead of one call per step.
|
|
301
|
+
- **Read the tree when text is enough.** `ios_get_ui_tree` / `android_get_ui_tree` cost a fraction of a screenshot and return tappable coordinates.
|
|
302
|
+
|
|
296
303
|
### How It Works
|
|
297
304
|
|
|
298
305
|
```mermaid
|
package/README.tr.md
CHANGED
|
@@ -292,6 +292,13 @@ Mock-mode vs Figma tasarım denetimi. Jenerik ve platform-bağımsız - ağır o
|
|
|
292
292
|
- **App Store screenshot'ları** - Temiz status bar + otomatik navigasyon
|
|
293
293
|
- **Regresyon testi** - Akışlarda tap'le gez, davranışı doğrula
|
|
294
294
|
|
|
295
|
+
### Performans
|
|
296
|
+
|
|
297
|
+
- **Inline capture'lar küçük.** `path` verilmeyen `ios_screenshot` / `android_screenshot` 800 px JPEG döner: simülatörün ürettiği ~3 MB tam çözünürlüklü PNG (her capture'da base64 olarak ~4 MB) yerine yaklaşık 60 KB. `max_width`, `format` ve `quality` ile ayarlanır; `format: "png"` + `max_width: 0` orijinal byte'ları döner. Tam çözünürlüklü PNG diskte kalır, `path` onu istediğin yere yazar.
|
|
298
|
+
- **iOS UI-tree dumper bir kez derlenir.** `ui-tree-dumper.swift` ilk kullanımda `swiftc -O` ile derlenir, kaynak + toolchain başına önbelleğe alınır; bir çağrı ~1 s'den ~0.05 s'ye iner. `swiftc` yoksa eskisi gibi yorumlanarak çalışır.
|
|
299
|
+
- **Senaryoyu tek çağrıda koş.** `agent_run_steps` tap, type, wait ve screenshot adımlarını adım başına bir çağrı yerine tek round trip'te çalıştırır.
|
|
300
|
+
- **Metin yetiyorsa tree oku.** `ios_get_ui_tree` / `android_get_ui_tree` bir screenshot'ın çok küçük bir maliyetiyle tap'lenebilir koordinatlar döner.
|
|
301
|
+
|
|
295
302
|
### Nasıl Çalışır
|
|
296
303
|
|
|
297
304
|
```mermaid
|
package/index.js
CHANGED
|
@@ -16,9 +16,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
16
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
17
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
18
18
|
import { execSync, exec, spawn } from "child_process";
|
|
19
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs";
|
|
19
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, renameSync } from "fs";
|
|
20
20
|
import { join, dirname, basename, isAbsolute, resolve, sep } from "path";
|
|
21
|
-
import { homedir } from "os";
|
|
21
|
+
import { homedir, tmpdir } from "os";
|
|
22
|
+
import { createHash } from "crypto";
|
|
22
23
|
import { fileURLToPath } from "url";
|
|
23
24
|
import { runAudit as runAppStoreAudit } from "./tools/ios-app-store-audit/index.js";
|
|
24
25
|
import {
|
|
@@ -37,11 +38,16 @@ import {
|
|
|
37
38
|
offloadLargeText,
|
|
38
39
|
queryOffloadedOutput,
|
|
39
40
|
offloadedErrorSummary,
|
|
41
|
+
pruneWorkDir,
|
|
40
42
|
} from "./tools/offload/index.js";
|
|
41
43
|
|
|
42
44
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
45
|
const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
|
|
44
46
|
if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
47
|
+
// Same retention as the offload dir: this directory collects screenshots, UI
|
|
48
|
+
// dumps, push payloads, build logs and .xcresult bundles, and nothing else
|
|
49
|
+
// ever removed them.
|
|
50
|
+
pruneWorkDir(SCREENSHOT_DIR);
|
|
45
51
|
|
|
46
52
|
// ── Helpers ──
|
|
47
53
|
|
|
@@ -80,6 +86,27 @@ function run(cmd, opts = {}) {
|
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
// run() reports failure by returning a truthy marker string, so `run(a) || run(b)`
|
|
90
|
+
// never falls back. This variant returns null on failure for exactly that shape.
|
|
91
|
+
function runOrNull(cmd, opts = {}) {
|
|
92
|
+
const out = run(cmd, opts);
|
|
93
|
+
return isFailure(out) ? null : out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A capture that must be fresh: a file left by an earlier run would otherwise
|
|
97
|
+
// pass the existence check and be returned as this run's result.
|
|
98
|
+
function discardStale(path) {
|
|
99
|
+
try { if (statSync(path).isFile()) unlinkSync(path); } catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The files a result promises to the caller; retention never removes them.
|
|
103
|
+
function promisedPaths(result) {
|
|
104
|
+
if (!result || typeof result !== "object") return [];
|
|
105
|
+
if (result.type === "file") return result.files.map((f) => f.path);
|
|
106
|
+
if (result.type === "image") return [result.path, result.source].filter(Boolean);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
83
110
|
/**
|
|
84
111
|
* Run a command whose non-zero exit is a RESULT, not a failure.
|
|
85
112
|
*
|
|
@@ -215,6 +242,64 @@ function shq(value) {
|
|
|
215
242
|
return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
// `adb shell <cmd>` is parsed twice: once by the host shell, again by the
|
|
246
|
+
// device shell. One shq() layer survives only the first, so the device shell
|
|
247
|
+
// saw `;`, `&` and `$()` in a text or URL as syntax. The inner layer is what
|
|
248
|
+
// the device shell receives.
|
|
249
|
+
function remoteShq(value) {
|
|
250
|
+
return shq(shq(value));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The ui-tree dumper prints its failure as a JSON object on stdout (with the
|
|
254
|
+
// remedy) and exits 1; a plain exit-status error keeps only stderr.
|
|
255
|
+
function dumperErrorOf(text) {
|
|
256
|
+
const s = String(text ?? "");
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(s);
|
|
259
|
+
return parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
260
|
+
} catch {}
|
|
261
|
+
const m = s.match(/"error"\s*:\s*("(?:[^"\\]|\\.)*")/);
|
|
262
|
+
if (!m) return null;
|
|
263
|
+
try { return JSON.parse(m[1]); } catch { return null; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const DUMPER_SOURCE = join(__dirname, "ui-tree-dumper.swift");
|
|
267
|
+
|
|
268
|
+
// `swift <script>` re-parses and JITs the dumper on every call (0.9-1.2 s); the
|
|
269
|
+
// -O binary answers in 0.05 s. Compiled on first use, never at server start,
|
|
270
|
+
// and keyed by source + toolchain so an edit or an Xcode update rebuilds it.
|
|
271
|
+
// The directory comes from the environment and the home directory only, never
|
|
272
|
+
// from a tool argument, the same rule the offload dir follows.
|
|
273
|
+
function dumperCacheDir() {
|
|
274
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
275
|
+
const base = xdg && isAbsolute(xdg) ? xdg
|
|
276
|
+
: process.platform === "darwin" ? join(homedir(), "Library", "Caches")
|
|
277
|
+
: join(homedir(), ".cache");
|
|
278
|
+
for (const dir of [join(base, "multi-agent-toolkit-mcp"), join(tmpdir(), "multi-agent-toolkit-mcp")]) {
|
|
279
|
+
try { mkdirSync(dir, { recursive: true }); return dir; } catch {}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let dumperPrefix = null;
|
|
285
|
+
function dumperCommand() {
|
|
286
|
+
if (dumperPrefix) return dumperPrefix;
|
|
287
|
+
dumperPrefix = `swift ${shq(DUMPER_SOURCE)}`;
|
|
288
|
+
const version = runOrNull("swiftc --version", { timeout: 15000 });
|
|
289
|
+
const dir = version ? dumperCacheDir() : null;
|
|
290
|
+
if (!dir) return dumperPrefix;
|
|
291
|
+
const key = createHash("sha256").update(readFileSync(DUMPER_SOURCE)).update(version).digest("hex").slice(0, 16);
|
|
292
|
+
const binary = join(dir, `ui-tree-dumper-${key}`);
|
|
293
|
+
if (!existsSync(binary)) {
|
|
294
|
+
const staging = `${binary}.${process.pid}`;
|
|
295
|
+
const built = run(`swiftc -O -o ${shq(staging)} ${shq(DUMPER_SOURCE)}`, { timeout: 120000 });
|
|
296
|
+
if (isFailure(built) || !existsSync(staging)) return dumperPrefix;
|
|
297
|
+
try { renameSync(staging, binary); } catch { return dumperPrefix; }
|
|
298
|
+
}
|
|
299
|
+
dumperPrefix = shq(binary);
|
|
300
|
+
return dumperPrefix;
|
|
301
|
+
}
|
|
302
|
+
|
|
218
303
|
// Numeric coordinates / scales: reject anything that is not a plain number so it
|
|
219
304
|
// can be interpolated bare.
|
|
220
305
|
function num(value, label) {
|
|
@@ -249,6 +334,39 @@ function hasCommand(cmd) {
|
|
|
249
334
|
}
|
|
250
335
|
|
|
251
336
|
const HAS_XCRUN = hasCommand("xcrun");
|
|
337
|
+
const HAS_SIPS = hasCommand("sips");
|
|
338
|
+
|
|
339
|
+
// The inline image goes to the model on every capture, and a simulator PNG is
|
|
340
|
+
// ~2.9 MB (3.9 MB as base64). sips turns it into an 800 px JPEG of ~60 KB in
|
|
341
|
+
// 0.12 s, so that is the default; the full-resolution file stays on disk.
|
|
342
|
+
function captureOptions(args) {
|
|
343
|
+
const format = args.format === "png" ? "png" : "jpeg";
|
|
344
|
+
const maxWidth = num(args.max_width ?? 800, "max_width");
|
|
345
|
+
const quality = num(args.quality ?? 80, "quality");
|
|
346
|
+
if (!Number.isInteger(maxWidth) || maxWidth < 0) throw new Error(`Invalid max_width: ${args.max_width} (0 or a positive pixel count)`);
|
|
347
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) throw new Error(`Invalid quality: ${args.quality} (1-100)`);
|
|
348
|
+
return { format, maxWidth, quality };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function inlineCapture(f, { format, maxWidth, quality }) {
|
|
352
|
+
const asIs = (note) => ({ type: "image", data: readFileSync(f).toString("base64"), mimeType: "image/png", path: f, note });
|
|
353
|
+
if (format === "png" && maxWidth === 0) return asIs("");
|
|
354
|
+
if (!HAS_SIPS) return asIs(" (sips unavailable, full-resolution PNG returned)");
|
|
355
|
+
const out = f.replace(/\.png$/, "") + (format === "jpeg" ? ".jpg" : `_${maxWidth}.png`);
|
|
356
|
+
discardStale(out);
|
|
357
|
+
const scale = maxWidth ? ` -Z ${maxWidth}` : "";
|
|
358
|
+
const options = format === "jpeg" ? ` -s formatOptions ${quality}` : "";
|
|
359
|
+
const converted = run(`sips${scale} -s format ${format}${options} ${shq(f)} --out ${shq(out)}`);
|
|
360
|
+
if (isFailure(converted) || !existsSync(out)) return asIs(" (sips failed, full-resolution PNG returned)");
|
|
361
|
+
return {
|
|
362
|
+
type: "image",
|
|
363
|
+
data: readFileSync(out).toString("base64"),
|
|
364
|
+
mimeType: format === "jpeg" ? "image/jpeg" : "image/png",
|
|
365
|
+
path: out,
|
|
366
|
+
source: f,
|
|
367
|
+
note: ` (full-resolution PNG: ${f})`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
252
370
|
|
|
253
371
|
// idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
|
|
254
372
|
// operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
|
|
@@ -325,9 +443,9 @@ function iosDevice(id) {
|
|
|
325
443
|
const IOS_TOOLS = [
|
|
326
444
|
{ name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
|
|
327
445
|
{ name: "ios_boot_device", description: "Boot an iOS simulator by UDID or name", inputSchema: { type: "object", properties: { device: { type: "string", description: "Device UDID or name" } }, required: ["device"] } },
|
|
328
|
-
{ name: "ios_screenshot", description: "Capture an iOS simulator screenshot.
|
|
446
|
+
{ name: "ios_screenshot", description: "Capture an iOS simulator screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of the ~3 MB full-resolution PNG, which is ~4 MB as base64 on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. The full-resolution PNG stays on disk either way. Pass `path` to write the full-resolution PNG there and return only its location, which is what a run taking many captures needs - and what design-check and ios_visual_diff read. Use when you need to see the screen, or to collect captures for a later comparison.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
|
|
329
447
|
{ name: "ios_tap", description: "Tap at coordinates on iOS simulator", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
|
|
330
|
-
{ name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
448
|
+
{ name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number", description: "Swipe duration in milliseconds" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
331
449
|
{ name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
332
450
|
{ name: "ios_launch_app", description: "Launch iOS app by bundle ID", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
|
|
333
451
|
{ name: "ios_terminate_app", description: "Terminate iOS app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
|
|
@@ -357,7 +475,7 @@ const IOS_TOOLS = [
|
|
|
357
475
|
{ name: "ios_export_ipa", description: "Export a .xcarchive to a signed .ipa via xcodebuild -exportArchive. Generates the exportOptions.plist from the arguments (method defaults to app-store-connect), so callers do not have to hand-maintain one. Returns the .ipa path plus parsed errors; a run that exits 0 without producing an .ipa is reported as a failure. Pair with ios_testflight_validate for the pre-submission gate.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive" }, output_dir: { type: "string", description: "Directory to write the .ipa into" }, method: { type: "string", description: "Export method: app-store-connect (default) | release-testing | enterprise | development" }, team_id: { type: "string", description: "Apple Developer team ID" }, provisioning_profiles: { type: "object", description: "Map of bundleId -> provisioning profile name (manual signing)" }, signing_style: { type: "string", description: "automatic | manual" }, upload_symbols: { type: "boolean", description: "Include symbols (default true)" }, allow_provisioning_updates: { type: "boolean", description: "Off by default. Lets xcodebuild register devices and create/modify provisioning profiles in the developer account - a change on Apple's side, so it is opt-in" }, timeout_sec: { type: "number", description: "Default 900" } }, required: ["archive_path", "output_dir"] } },
|
|
358
476
|
{ name: "ios_testflight_validate", description: "Run Apple's own pre-submission validation on an .ipa via `xcrun altool --validate-app`, then map returned ITMS error codes onto the App Store rule each one implies. This is the authoritative gate: unlike the static ios_app_store_audit it can catch an unregistered bundle ID, a profile that does not match the App Store Connect app record, a version+build pair already used, and entitlements not provisioned for the app ID. Auth is a 3-tier chain: ASC API key (api_key_id + api_issuer_id), else Apple ID + app-specific password referenced indirectly through a keychain item or env var (never passed by value), else the gate returns verdict SKIPPED with a reason. SKIPPED is not a pass - it means Apple was never asked. Set list_providers=true for a pre-flight that reports which teams the credentials can deliver for (needed when a corporate Apple ID belongs to several).", inputSchema: { type: "object", properties: { ipa_path: { type: "string", description: "Absolute path to the .ipa" }, platform: { type: "string", description: "ios (default) | appletvos | visionos | macos" }, api_key_id: { type: "string", description: "App Store Connect API key ID (tier 1)" }, api_issuer_id: { type: "string", description: "App Store Connect issuer ID (tier 1)" }, p8_path: { type: "string", description: "Explicit path to AuthKey_<id>.p8; otherwise altool's search dirs are used" }, apple_id: { type: "string", description: "Apple ID (tier 2)" }, keychain_item: { type: "string", description: "Keychain item holding the app-specific password (tier 2, preferred)" }, password_env_var: { type: "string", description: "Env var holding the app-specific password (tier 2 fallback)" }, provider_public_id: { type: "string", description: "Required when the account belongs to multiple providers" }, list_providers: { type: "boolean", description: "Pre-flight only: list deliverable providers and return" }, timeout_sec: { type: "number", description: "Default 900" } }, required: [] } },
|
|
359
477
|
{ name: "ios_app_store_audit", description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, SDK floor (ITMS-90725), and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces ios_archive_audit (deprecated). Pass rules='core' for the 6 baseline checks (code-signing, entitlements, info-plist, privacy-manifest, binary-size, sdk-floor); 'all'|'deep' for the full 18-rule scan; CSV like 'binary-size,ipv6-compliance' for an explicit subset.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" }, rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" } }, required: ["archive_path"] } },
|
|
360
|
-
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
478
|
+
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator; for action test, the booted simulator (xcodebuild refuses a generic destination for test)" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
361
479
|
{ name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed), metrics (XCTMetric performance results as JSON). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
|
|
362
480
|
{ name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
|
|
363
481
|
{ name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
|
|
@@ -365,8 +483,12 @@ const IOS_TOOLS = [
|
|
|
365
483
|
{ name: "ios_list_crashes", description: "List recent crash reports from the host's ~/Library/Logs/DiagnosticReports - where simulator app crashes land. Filter by process name, bound by age and count.", inputSchema: { type: "object", properties: { app: { type: "string", description: "Only reports whose file name (the crashed process) contains this substring" }, since_min: { type: "number", description: "Only reports newer than this many minutes" }, limit: { type: "number", description: "Max reports returned, newest first (default 20)" } } } },
|
|
366
484
|
];
|
|
367
485
|
|
|
486
|
+
// Pure-JS members of the ios_* family: comparing two PNGs or listing crash
|
|
487
|
+
// reports needs no simulator, so a missing Xcode must not refuse them.
|
|
488
|
+
const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
|
|
489
|
+
|
|
368
490
|
async function handleIOS(name, args, ctx = {}) {
|
|
369
|
-
if (!HAS_XCRUN) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
|
|
491
|
+
if (!HAS_XCRUN && !IOS_TOOLS_WITHOUT_XCRUN.has(name)) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
|
|
370
492
|
const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
|
|
371
493
|
|
|
372
494
|
switch (name) {
|
|
@@ -393,11 +515,13 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
393
515
|
const parent = dirname(f);
|
|
394
516
|
if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
|
|
395
517
|
}
|
|
396
|
-
|
|
518
|
+
const inline = args.path ? null : captureOptions(args);
|
|
519
|
+
discardStale(f);
|
|
520
|
+
const shot = run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
|
|
521
|
+
if (isFailure(shot)) return shot;
|
|
397
522
|
if (!existsSync(f)) return "ERROR: Screenshot failed";
|
|
398
523
|
if (args.path) return fileResult(`Screenshot written: ${f}`, f);
|
|
399
|
-
|
|
400
|
-
return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
|
|
524
|
+
return inlineCapture(f, inline);
|
|
401
525
|
}
|
|
402
526
|
case "ios_tap": { const d = iosDevice(args.device_id); const r = idb(`ui tap --udid ${d} ${num(args.x, "x")} ${num(args.y, "y")}`); return (typeof r === "string" && r.startsWith("ERROR")) ? r : `Tapped (${args.x}, ${args.y})`; }
|
|
403
527
|
case "ios_swipe": { const d = iosDevice(args.device_id); const dur = args.duration_ms ? ` --duration ${(args.duration_ms / 1000).toFixed(2)}` : ""; const r = idb(`ui swipe --udid ${d} ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")}${dur}`); return isFailure(r) ? r : "Swiped"; }
|
|
@@ -416,7 +540,19 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
416
540
|
}
|
|
417
541
|
case "ios_set_appearance": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} appearance ${token(args.mode, "appearance mode")}`) || `Appearance: ${args.mode}`; }
|
|
418
542
|
case "ios_set_content_size": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} content_size ${token(args.size, "content size")}`) || `Content size: ${args.size}`; }
|
|
419
|
-
case "ios_set_locale": {
|
|
543
|
+
case "ios_set_locale": {
|
|
544
|
+
const d = iosDevice(args.device_id);
|
|
545
|
+
const bid = sanitizeId(args.bundle_id);
|
|
546
|
+
const written = run(`xcrun simctl spawn ${d} defaults write ${bid} AppleLanguages -array ${shq(args.language)}`);
|
|
547
|
+
if (isFailure(written)) return written;
|
|
548
|
+
// An app that is not running has nothing to terminate; that is not a
|
|
549
|
+
// failure of the locale change, which applies at the next launch.
|
|
550
|
+
const stopped = run(`xcrun simctl terminate ${d} ${bid}`);
|
|
551
|
+
if (isFailure(stopped) && !/No such process|found nothing to terminate|not running/i.test(stopped)) return stopped;
|
|
552
|
+
const launched = run(`xcrun simctl launch ${d} ${bid}`);
|
|
553
|
+
if (isFailure(launched)) return launched;
|
|
554
|
+
return `Locale: ${args.language} (${bid} relaunched)`;
|
|
555
|
+
}
|
|
420
556
|
case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
|
|
421
557
|
case "ios_status_bar": {
|
|
422
558
|
const d = iosDevice(args.device_id);
|
|
@@ -481,11 +617,10 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
481
617
|
}
|
|
482
618
|
case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
|
|
483
619
|
case "ios_keychain_reset": { const d = iosDevice(args.device_id); return run(`xcrun simctl keychain ${d} reset`) || "Keychain reset"; }
|
|
484
|
-
case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${args.container || "app"}`); }
|
|
620
|
+
case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${token(args.container || "app", "container")}`); }
|
|
485
621
|
case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
|
|
486
622
|
case "ios_get_ui_tree": {
|
|
487
|
-
|
|
488
|
-
if (!existsSync(script)) return "ui-tree-dumper.swift not found";
|
|
623
|
+
if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
|
|
489
624
|
// Path check first, matching the Android sibling: the AX dump takes up to
|
|
490
625
|
// 15s, too expensive to spend before rejecting a bad destination.
|
|
491
626
|
if (args.path) {
|
|
@@ -493,16 +628,18 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
493
628
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
494
629
|
}
|
|
495
630
|
const depth = num(args.max_depth ?? 10, "max_depth");
|
|
496
|
-
const tree =
|
|
497
|
-
if (
|
|
631
|
+
const tree = runCapture(`${dumperCommand()} ${depth}`, { timeout: 15000 });
|
|
632
|
+
if (isFailure(tree)) return tree;
|
|
633
|
+
const dumperError = dumperErrorOf(tree);
|
|
634
|
+
if (dumperError) return `${ERROR_PREFIX}${dumperError}`;
|
|
635
|
+
if (!args.path) return tree;
|
|
498
636
|
writeFileSync(String(args.path), tree);
|
|
499
637
|
return fileResult(`UI tree written: ${args.path}`, String(args.path));
|
|
500
638
|
}
|
|
501
639
|
case "ios_accessibility_audit": {
|
|
502
|
-
|
|
503
|
-
if (!existsSync(script)) return "ui-tree-dumper.swift not found";
|
|
640
|
+
if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
|
|
504
641
|
const depth = num(args.max_depth ?? 10, "max_depth");
|
|
505
|
-
const treeJson = run(
|
|
642
|
+
const treeJson = run(`${dumperCommand()} ${depth}`, { timeout: 30000 });
|
|
506
643
|
let tree = null;
|
|
507
644
|
try {
|
|
508
645
|
tree = JSON.parse(treeJson);
|
|
@@ -553,14 +690,14 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
553
690
|
const binaryName = appName.replace(".app", "");
|
|
554
691
|
const binaryPath = `${appDir}/${binaryName}`;
|
|
555
692
|
// 2. Binary size
|
|
556
|
-
const
|
|
557
|
-
if (sizeOut) { const sizeMB = parseInt(sizeOut) / 1048576; findings.push({ check: "binary_size", value: `${sizeMB.toFixed(1)} MB`, status: sizeMB > 500 ? "warning" : "pass", detail: sizeMB > 500 ? "Binary exceeds 500MB - may hit App Store limits" : "OK" }); }
|
|
693
|
+
try { const sizeMB = statSync(binaryPath).size / 1048576; findings.push({ check: "binary_size", value: `${sizeMB.toFixed(1)} MB`, status: sizeMB > 500 ? "warning" : "pass", detail: sizeMB > 500 ? "Binary exceeds 500MB - may hit App Store limits" : "OK" }); } catch {}
|
|
558
694
|
// 3. Debug tool leak check
|
|
559
695
|
const debugSymbols = run(`nm ${shq(binaryPath)} 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
|
|
560
696
|
findings.push({ check: "debug_tools", status: debugSymbols ? "critical" : "pass", detail: debugSymbols ? `Debug tools found in binary: ${debugSymbols}` : "No debug tools detected" });
|
|
561
|
-
// 4. Code signing
|
|
562
|
-
|
|
563
|
-
const
|
|
697
|
+
// 4. Code signing. codesign exits 1 on an unsigned bundle, and run()'s
|
|
698
|
+
// failure marker does not carry its "not signed" line; runCapture does.
|
|
699
|
+
const codesign = runCapture(`codesign -dvv ${shq(appDir)} 2>&1`);
|
|
700
|
+
const hasSignature = codesign && !isFailure(codesign) && !codesign.includes("not signed");
|
|
564
701
|
findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
|
|
565
702
|
// 5. Provisioning profile
|
|
566
703
|
const provProfile = run(`ls ${shq(appDir + "/embedded.mobileprovision")} 2>/dev/null`);
|
|
@@ -689,7 +826,16 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
689
826
|
if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
|
|
690
827
|
const action = args.action || "build";
|
|
691
828
|
const config = args.configuration || "Release";
|
|
692
|
-
|
|
829
|
+
let dest = args.destination;
|
|
830
|
+
if (!dest) {
|
|
831
|
+
if (action === "test") {
|
|
832
|
+
const d = did();
|
|
833
|
+
if (!d) return `${ERROR_PREFIX}action "test" needs a booted simulator: xcodebuild refuses generic/platform=iOS Simulator for test. Boot one with ios_boot_device, or pass destination explicitly.`;
|
|
834
|
+
dest = `platform=iOS Simulator,id=${d}`;
|
|
835
|
+
} else {
|
|
836
|
+
dest = "generic/platform=iOS Simulator";
|
|
837
|
+
}
|
|
838
|
+
}
|
|
693
839
|
const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
694
840
|
const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
|
|
695
841
|
const logPath = join(SCREENSHOT_DIR, `${id}.log`);
|
|
@@ -804,13 +950,21 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
804
950
|
return diffImagePath ? fileResult(report, diffImagePath) : report;
|
|
805
951
|
}
|
|
806
952
|
case "ios_leaks": {
|
|
807
|
-
|
|
953
|
+
// The schema validator accepts a numeric string for `pid`, so coerce
|
|
954
|
+
// rather than re-check the raw type here.
|
|
955
|
+
const pidNum = args.pid === undefined || args.pid === null || args.pid === "" ? NaN : Number(args.pid);
|
|
956
|
+
let pid = Number.isInteger(pidNum) && pidNum > 0 ? pidNum : null;
|
|
808
957
|
if (!pid && args.bundle_id) {
|
|
809
958
|
const d = iosDevice(args.device_id);
|
|
810
|
-
const
|
|
811
|
-
const
|
|
812
|
-
if (
|
|
813
|
-
|
|
959
|
+
const id = sanitizeId(args.bundle_id);
|
|
960
|
+
const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null`);
|
|
961
|
+
if (isFailure(out)) return out;
|
|
962
|
+
// Whole-token match: a substring grep of com.x also matched com.xy, and
|
|
963
|
+
// the dots were regex wildcards.
|
|
964
|
+
const label = new RegExp(`(^|[^A-Za-z0-9._-])${id.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}($|[^A-Za-z0-9._-])`);
|
|
965
|
+
const line = out.split("\n").find((l) => /^\d+\s/.test(l) && label.test(l));
|
|
966
|
+
if (!line) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
|
|
967
|
+
pid = parseInt(line, 10);
|
|
814
968
|
}
|
|
815
969
|
if (!pid) return "ERROR: pass pid or bundle_id";
|
|
816
970
|
|
|
@@ -823,8 +977,12 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
823
977
|
const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
|
|
824
978
|
// leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
|
|
825
979
|
// not a failure. Everything below is decided from the parsed output.
|
|
826
|
-
|
|
827
|
-
|
|
980
|
+
// spawnCollect rather than execSync: a two-minute scan must not block the
|
|
981
|
+
// event loop, and its output is capped instead of killing the child.
|
|
982
|
+
const res = await spawnCollect(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000, signal: ctx.signal });
|
|
983
|
+
if (res.timedOut) return `${ERROR_PREFIX}leaks did not finish within 120s for pid ${pid}`;
|
|
984
|
+
if (res.aborted) return `${ERROR_PREFIX}leaks cancelled`;
|
|
985
|
+
const parsed = parseLeaksOutput(res.output);
|
|
828
986
|
return JSON.stringify({
|
|
829
987
|
pid,
|
|
830
988
|
mode,
|
|
@@ -847,14 +1005,28 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
847
1005
|
const only = args.test_identifier ? ` -only-testing:${shq(args.test_identifier)}` : "";
|
|
848
1006
|
// xcodebuild exits non-zero when the audit finds anything, because each
|
|
849
1007
|
// finding is an XCTest failure. That is a result, not a failure to run.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
);
|
|
1008
|
+
// spawnCollect: a 15-minute test run through execSync blocked the event
|
|
1009
|
+
// loop for its whole duration, and its 1MB maxBuffer killed the child on
|
|
1010
|
+
// any real build log.
|
|
1011
|
+
const stopHeartbeat = startHeartbeat(ctx, "xcodebuild test (accessibility audit)");
|
|
1012
|
+
let build;
|
|
1013
|
+
try {
|
|
1014
|
+
build = await spawnCollect(
|
|
1015
|
+
`xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
|
|
1016
|
+
{ timeout: 900000, signal: ctx.signal },
|
|
1017
|
+
);
|
|
1018
|
+
} finally {
|
|
1019
|
+
stopHeartbeat();
|
|
1020
|
+
}
|
|
854
1021
|
if (!existsSync(bundle)) {
|
|
855
|
-
|
|
1022
|
+
const reason = build.timedOut
|
|
1023
|
+
? "xcodebuild did not finish within 900s; no result bundle was written"
|
|
1024
|
+
: build.aborted
|
|
1025
|
+
? "the request was cancelled before xcodebuild finished"
|
|
1026
|
+
: "xcodebuild produced no result bundle; the build failed before any test ran";
|
|
1027
|
+
return JSON.stringify({ measurable: false, reason, test_ran: false, findings: [] }, null, 2);
|
|
856
1028
|
}
|
|
857
|
-
const json =
|
|
1029
|
+
const json = (await spawnCollect(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000, signal: ctx.signal })).output;
|
|
858
1030
|
const r = parseAuditResults(json, args.test_identifier || null);
|
|
859
1031
|
return JSON.stringify({
|
|
860
1032
|
measurable: r.measurable,
|
|
@@ -897,14 +1069,23 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
897
1069
|
// model-controlled device_id a command injection.
|
|
898
1070
|
function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
|
|
899
1071
|
|
|
1072
|
+
// One rule for both launchers: `activity` is the class, relative or fully
|
|
1073
|
+
// qualified, and the component is package/activity. A value that already
|
|
1074
|
+
// carries the package (com.x/.Main) is used as it is.
|
|
1075
|
+
function androidComponent(packageName, activity) {
|
|
1076
|
+
const pkg = sanitizeId(packageName);
|
|
1077
|
+
const act = sanitizeId(activity);
|
|
1078
|
+
return act.includes("/") ? act : `${pkg}/${act}`;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
900
1081
|
const ANDROID_TOOLS = [
|
|
901
1082
|
{ name: "android_list_devices", description: "List connected Android devices and emulators", inputSchema: { type: "object", properties: {} } },
|
|
902
|
-
{ name: "android_screenshot", description: "Capture an Android screenshot.
|
|
1083
|
+
{ name: "android_screenshot", description: "Capture an Android screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of a multi-MB full-resolution PNG on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. Downscaling needs sips (macOS); without it the original PNG is returned and the result says so. Pass `path` to write the full-resolution PNG there and return only its location - what a multi-capture run and the design tools need.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
|
|
903
1084
|
{ name: "android_tap", description: "Tap at coordinates on Android", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
|
|
904
1085
|
{ name: "android_swipe", description: "Swipe on Android", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
905
|
-
{ name: "android_type_text", description: "Type text on Android", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
1086
|
+
{ name: "android_type_text", description: "Type text on Android via `adb shell input text`. The text reaches the device shell as a quoted literal, so shell characters are typed, not interpreted. Platform limit: `input text` drops non-ASCII characters and some symbols; use the keyboard for those.", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
906
1087
|
{ name: "android_key_event", description: "Send Android key event (HOME=3, BACK=4, ENTER=66)", inputSchema: { type: "object", properties: { keycode: { type: "number" }, device_id: { type: "string" } }, required: ["keycode"] } },
|
|
907
|
-
{ name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
1088
|
+
{ name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Omit to launch through the LAUNCHER intent." }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
908
1089
|
{ name: "android_stop_app", description: "Force-stop Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
909
1090
|
{ name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
|
|
910
1091
|
{ name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
|
|
@@ -924,13 +1105,26 @@ const ANDROID_TOOLS = [
|
|
|
924
1105
|
{ name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
|
|
925
1106
|
{ name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
926
1107
|
{ name: "android_accessibility_audit", description: "Audit Android app accessibility on the connected device: missing contentDescription, clickable nodes TalkBack cannot focus, touch targets under 48dp, missing resource-ids, and whether the reading order follows the visual layout. Reports measurable:false with a reason rather than a clean result when the dump could not be read.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." }, rtl: { type: "boolean", description: "Expect right-to-left reading within a row. Default false." } } } },
|
|
927
|
-
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
1108
|
+
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Default .MainActivity." }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
928
1109
|
{ name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
|
|
929
1110
|
{ name: "android_meminfo", description: "Read an Android app's memory via `adb shell dumpsys meminfo` (KB, no root). mode=snapshot returns the App Summary rows and totals; mode=diff compares two snapshots so growth across the same flow is visible, which is the signal a leak actually produces - a single absolute number says almost nothing. Reports measurable:false when the package has no running process rather than returning zeros.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_json: { type: "string", description: "mode=diff: the JSON returned by an earlier snapshot call" } }, required: ["package_name"] } },
|
|
930
1111
|
{ name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
|
|
931
1112
|
{ name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
|
|
932
1113
|
];
|
|
933
1114
|
|
|
1115
|
+
// The snapshot shape the tool emits, and the shape diffMeminfo accepts back
|
|
1116
|
+
// as a baseline. One function so the two cannot drift apart again.
|
|
1117
|
+
function meminfoPayload(snapshot) {
|
|
1118
|
+
return {
|
|
1119
|
+
measurable: snapshot.measurable,
|
|
1120
|
+
reason: snapshot.reason,
|
|
1121
|
+
pss_kb: snapshot.pss,
|
|
1122
|
+
total_pss_kb: snapshot.totalPssKb,
|
|
1123
|
+
total_rss_kb: snapshot.totalRssKb,
|
|
1124
|
+
total_swap_kb: snapshot.totalSwapKb,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
934
1128
|
async function handleAndroid(name, args, ctx = {}) {
|
|
935
1129
|
if (!HAS_ADB) return `${ERROR_PREFIX}Android SDK not installed - adb not on PATH, so Android tools cannot run. Install platform-tools and ensure adb is on PATH.`;
|
|
936
1130
|
const df = adbFlag(args.device_id);
|
|
@@ -943,21 +1137,24 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
943
1137
|
const parent = dirname(f);
|
|
944
1138
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
945
1139
|
}
|
|
946
|
-
|
|
947
|
-
|
|
1140
|
+
const inline = args.path ? null : captureOptions(args);
|
|
1141
|
+
discardStale(f);
|
|
1142
|
+
const cap = run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
|
|
1143
|
+
if (isFailure(cap)) return cap;
|
|
1144
|
+
const pulled = run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
|
|
948
1145
|
run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
|
|
1146
|
+
if (isFailure(pulled)) return pulled;
|
|
949
1147
|
if (!existsSync(f)) return "ERROR: Screenshot failed";
|
|
950
1148
|
if (args.path) return fileResult(`Screenshot written: ${f}`, f);
|
|
951
|
-
|
|
952
|
-
return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
|
|
1149
|
+
return inlineCapture(f, inline);
|
|
953
1150
|
}
|
|
954
1151
|
case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
|
|
955
1152
|
case "android_swipe": return run(`adb ${df} shell input swipe ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")} ${num(args.duration_ms || 300, "duration_ms")}`) || "Swiped";
|
|
956
1153
|
// `adb shell input text` wants spaces as %s; single-quote the result so the
|
|
957
1154
|
// remaining characters cannot reach the shell as syntax.
|
|
958
|
-
case "android_type_text": return run(`adb ${df} shell input text ${
|
|
1155
|
+
case "android_type_text": return run(`adb ${df} shell input text ${remoteShq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
|
|
959
1156
|
case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
|
|
960
|
-
case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${
|
|
1157
|
+
case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${androidComponent(args.package_name, args.activity)}`) : run(`adb ${df} shell monkey -p ${sanitizeId(args.package_name)} -c android.intent.category.LAUNCHER 1`) || `Launched`;
|
|
961
1158
|
case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
|
|
962
1159
|
case "android_list_packages": { const out = run(`adb ${df} shell pm list packages`); return args.filter ? out.split("\n").filter(l => l.toLowerCase().includes(args.filter.toLowerCase())).join("\n") : out; }
|
|
963
1160
|
case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
|
|
@@ -968,9 +1165,12 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
968
1165
|
const parent = dirname(f);
|
|
969
1166
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
970
1167
|
}
|
|
971
|
-
|
|
972
|
-
run(`adb ${df}
|
|
1168
|
+
discardStale(f);
|
|
1169
|
+
const dumped = run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
|
|
1170
|
+
if (isFailure(dumped)) return dumped;
|
|
1171
|
+
const pulled = run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
|
|
973
1172
|
run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
|
|
1173
|
+
if (isFailure(pulled)) return pulled;
|
|
974
1174
|
if (!existsSync(f)) return "ERROR: UI dump failed";
|
|
975
1175
|
if (args.filter === "interactive") {
|
|
976
1176
|
const elements = interactiveElements(readFileSync(f, "utf-8"));
|
|
@@ -1076,7 +1276,7 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1076
1276
|
case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
|
|
1077
1277
|
case "android_logcat": { const lines = args.lines !== undefined ? num(args.lines, "lines") : 50; const tf = args.tag ? `| grep -i ${shq(args.tag)}` : ""; return run(`adb ${df} logcat -d ${tf} | tail -${lines}`); }
|
|
1078
1278
|
case "android_get_screen_size": return run(`adb ${df} shell wm size`);
|
|
1079
|
-
case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${
|
|
1279
|
+
case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${remoteShq(args.url)}`) || `Opened: ${args.url}`;
|
|
1080
1280
|
case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
|
|
1081
1281
|
case "android_accessibility_audit": {
|
|
1082
1282
|
run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
|
|
@@ -1101,8 +1301,8 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1101
1301
|
}
|
|
1102
1302
|
case "android_launch_time": {
|
|
1103
1303
|
run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
|
|
1104
|
-
const
|
|
1105
|
-
const result = run(`adb ${df} shell am start -W -n ${
|
|
1304
|
+
const component = androidComponent(args.package_name, args.activity || ".MainActivity");
|
|
1305
|
+
const result = run(`adb ${df} shell am start -W -n ${component} 2>&1`);
|
|
1106
1306
|
const parsed = parseLaunchOutput(result);
|
|
1107
1307
|
return JSON.stringify({
|
|
1108
1308
|
package: args.package_name,
|
|
@@ -1122,7 +1322,7 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1122
1322
|
if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
|
|
1123
1323
|
const findings = [];
|
|
1124
1324
|
// 1. Basic info via aapt2
|
|
1125
|
-
const aapt =
|
|
1325
|
+
const aapt = runOrNull(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || runOrNull(`aapt dump badging ${shq(p)} 2>/dev/null`);
|
|
1126
1326
|
if (aapt) {
|
|
1127
1327
|
const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
|
|
1128
1328
|
const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
|
|
@@ -1143,19 +1343,29 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1143
1343
|
} else {
|
|
1144
1344
|
findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
|
|
1145
1345
|
}
|
|
1146
|
-
// 2. Signing check
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1346
|
+
// 2. Signing check. apksigner exits 1 on a failed verification with the
|
|
1347
|
+
// verdict on stdout, so the output is captured whatever the status.
|
|
1348
|
+
const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
|
|
1349
|
+
if (/DOES NOT VERIFY/.test(signingInfo)) {
|
|
1350
|
+
findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
|
|
1351
|
+
} else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
|
|
1352
|
+
findings.push({ check: "signing", status: "warning", detail: "apksigner not found - install Android SDK Build-Tools to verify the signature" });
|
|
1353
|
+
} else if (/^(ERROR|Exception)/m.test(signingInfo)) {
|
|
1354
|
+
findings.push({ check: "signing", status: "warning", detail: signingInfo.slice(0, 500) });
|
|
1355
|
+
} else {
|
|
1356
|
+
const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
|
|
1150
1357
|
findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
|
|
1151
1358
|
findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
|
|
1152
|
-
} else {
|
|
1153
|
-
findings.push({ check: "signing", status: signingInfo?.includes("DOES NOT VERIFY") ? "critical" : "warning", detail: signingInfo || "apksigner not found" });
|
|
1154
1359
|
}
|
|
1155
1360
|
// 3. File size
|
|
1156
|
-
try {
|
|
1361
|
+
try {
|
|
1362
|
+
const mb = statSync(p).size / 1048576;
|
|
1363
|
+
findings.push({ check: "apk_size", value: `${mb.toFixed(1)} MB`, status: mb > 150 ? "warning" : "pass", detail: mb > 150 ? "APK > 150MB - consider App Bundle (.aab)" : "OK" });
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
findings.push({ check: "apk_size", status: "warning", detail: `could not read the file size: ${e.message}` });
|
|
1366
|
+
}
|
|
1157
1367
|
// 4. ProGuard/R8 check
|
|
1158
|
-
const hasMapping =
|
|
1368
|
+
const hasMapping = runOrNull(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`);
|
|
1159
1369
|
const dexCount = parseInt(hasMapping) || 0;
|
|
1160
1370
|
findings.push({ check: "dex_files", value: `${dexCount} dex file(s)`, status: dexCount > 3 ? "warning" : "info", detail: dexCount > 3 ? "Many DEX files - ensure R8/ProGuard minification is enabled" : "OK" });
|
|
1161
1371
|
// Summary
|
|
@@ -1177,18 +1387,9 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1177
1387
|
return "ERROR: baseline_json is not valid JSON";
|
|
1178
1388
|
}
|
|
1179
1389
|
const d = diffMeminfo(before, snapshot);
|
|
1180
|
-
return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: snapshot }, null, 2);
|
|
1390
|
+
return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: meminfoPayload(snapshot) }, null, 2);
|
|
1181
1391
|
}
|
|
1182
|
-
return JSON.stringify({
|
|
1183
|
-
package: args.package_name,
|
|
1184
|
-
mode,
|
|
1185
|
-
measurable: snapshot.measurable,
|
|
1186
|
-
reason: snapshot.reason,
|
|
1187
|
-
pss_kb: snapshot.pss,
|
|
1188
|
-
total_pss_kb: snapshot.totalPssKb,
|
|
1189
|
-
total_rss_kb: snapshot.totalRssKb,
|
|
1190
|
-
total_swap_kb: snapshot.totalSwapKb,
|
|
1191
|
-
}, null, 2);
|
|
1392
|
+
return JSON.stringify({ package: args.package_name, mode, ...meminfoPayload(snapshot) }, null, 2);
|
|
1192
1393
|
}
|
|
1193
1394
|
case "android_list_crashes": {
|
|
1194
1395
|
const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
|
|
@@ -1224,9 +1425,15 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1224
1425
|
|
|
1225
1426
|
let _browser = null;
|
|
1226
1427
|
let _page = null;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1428
|
+
let _engine = null;
|
|
1429
|
+
|
|
1430
|
+
// A request for a different engine closes the current browser and relaunches;
|
|
1431
|
+
// a partial launch (newContext/newPage threw) is closed rather than left as a
|
|
1432
|
+
// browser with no page that the next call would launch beside.
|
|
1433
|
+
async function ensureBrowser(browserType) {
|
|
1434
|
+
const wanted = browserType || _engine || "chromium";
|
|
1435
|
+
if (_browser && _page && _engine === wanted) return _page;
|
|
1436
|
+
if (_browser || _page) await closeBrowser();
|
|
1230
1437
|
let pw;
|
|
1231
1438
|
try {
|
|
1232
1439
|
pw = await import("playwright");
|
|
@@ -1234,10 +1441,17 @@ async function ensureBrowser(browserType = "chromium") {
|
|
|
1234
1441
|
throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
|
|
1235
1442
|
}
|
|
1236
1443
|
const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
|
|
1237
|
-
const engine = engines[
|
|
1444
|
+
const engine = engines[wanted];
|
|
1445
|
+
if (!engine) throw new Error(`unknown browser engine: ${wanted}`);
|
|
1238
1446
|
_browser = await engine.launch({ headless: true });
|
|
1239
|
-
|
|
1240
|
-
|
|
1447
|
+
try {
|
|
1448
|
+
const ctx = await _browser.newContext();
|
|
1449
|
+
_page = await ctx.newPage();
|
|
1450
|
+
} catch (e) {
|
|
1451
|
+
await closeBrowser();
|
|
1452
|
+
throw e;
|
|
1453
|
+
}
|
|
1454
|
+
_engine = wanted;
|
|
1241
1455
|
return _page;
|
|
1242
1456
|
}
|
|
1243
1457
|
|
|
@@ -1246,6 +1460,7 @@ async function closeBrowser() {
|
|
|
1246
1460
|
try { await _browser?.close(); } catch {}
|
|
1247
1461
|
_page = null;
|
|
1248
1462
|
_browser = null;
|
|
1463
|
+
_engine = null;
|
|
1249
1464
|
}
|
|
1250
1465
|
|
|
1251
1466
|
const WEB_TOOLS = [
|
|
@@ -1350,6 +1565,10 @@ const AGENT_TOOLS = [
|
|
|
1350
1565
|
// a batch able to nest itself has no recursion bound.
|
|
1351
1566
|
async function dispatchStep(tool, stepArgs) {
|
|
1352
1567
|
if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
|
|
1568
|
+
// The same boundary check a direct call gets: a batched step used to reach
|
|
1569
|
+
// the handler with its enum/type/required contract unenforced.
|
|
1570
|
+
const schemaError = validateArgs(tool, stepArgs);
|
|
1571
|
+
if (schemaError) throw new Error(`invalid arguments: ${schemaError}`);
|
|
1353
1572
|
let result;
|
|
1354
1573
|
if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
|
|
1355
1574
|
else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
|
|
@@ -1611,7 +1830,7 @@ const bundleAuditSchema = (pathKey) => ({
|
|
|
1611
1830
|
items: {
|
|
1612
1831
|
type: "object",
|
|
1613
1832
|
properties: {
|
|
1614
|
-
status: { type: "string", enum: ["pass", "warning", "critical"] },
|
|
1833
|
+
status: { type: "string", enum: ["pass", "warning", "critical", "info"] },
|
|
1615
1834
|
rule: { type: "string" },
|
|
1616
1835
|
message: { type: "string" },
|
|
1617
1836
|
},
|
|
@@ -1664,7 +1883,7 @@ const OUTPUT_SCHEMAS = {
|
|
|
1664
1883
|
max_diff_pct: { type: "number" },
|
|
1665
1884
|
baseline: { type: "string" },
|
|
1666
1885
|
current: { type: "string" },
|
|
1667
|
-
diff_image: { type: "string" },
|
|
1886
|
+
diff_image: { type: ["string", "null"] },
|
|
1668
1887
|
},
|
|
1669
1888
|
},
|
|
1670
1889
|
|
|
@@ -1694,9 +1913,11 @@ const OUTPUT_SCHEMAS = {
|
|
|
1694
1913
|
required: ["package", "cold_start"],
|
|
1695
1914
|
properties: {
|
|
1696
1915
|
package: { type: "string" },
|
|
1697
|
-
|
|
1916
|
+
launch_state: { type: ["string", "null"] },
|
|
1917
|
+
cold_start: { type: ["boolean", "null"] },
|
|
1698
1918
|
total_time_ms: { type: ["integer", "null"] },
|
|
1699
1919
|
wait_time_ms: { type: ["integer", "null"] },
|
|
1920
|
+
error: { type: ["string", "null"] },
|
|
1700
1921
|
raw: { type: "string" },
|
|
1701
1922
|
},
|
|
1702
1923
|
},
|
|
@@ -1733,9 +1954,11 @@ const OUTPUT_SCHEMAS = {
|
|
|
1733
1954
|
items: {
|
|
1734
1955
|
type: "object",
|
|
1735
1956
|
properties: {
|
|
1957
|
+
step: { type: "integer" },
|
|
1736
1958
|
tool: { type: "string" },
|
|
1737
1959
|
status: { type: "string", enum: ["ok", "error"] },
|
|
1738
|
-
|
|
1960
|
+
result: { type: "string" },
|
|
1961
|
+
error: { type: "string" },
|
|
1739
1962
|
},
|
|
1740
1963
|
},
|
|
1741
1964
|
},
|
|
@@ -1773,7 +1996,8 @@ const designCtx = {
|
|
|
1773
1996
|
adbFlag,
|
|
1774
1997
|
idb,
|
|
1775
1998
|
hasIdb: HAS_IDB,
|
|
1776
|
-
dumperScript:
|
|
1999
|
+
dumperScript: DUMPER_SOURCE,
|
|
2000
|
+
dumperCommand,
|
|
1777
2001
|
};
|
|
1778
2002
|
|
|
1779
2003
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
|
|
@@ -1821,11 +2045,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
1821
2045
|
if (result === null || result === undefined) {
|
|
1822
2046
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1823
2047
|
}
|
|
2048
|
+
pruneWorkDir(SCREENSHOT_DIR, { keep: promisedPaths(result) });
|
|
1824
2049
|
if (typeof result === "object" && result?.type === "image") {
|
|
1825
2050
|
return {
|
|
1826
2051
|
content: [
|
|
1827
2052
|
{ type: "image", data: result.data, mimeType: result.mimeType },
|
|
1828
|
-
{ type: "text", text: `Screenshot: ${result.path}` },
|
|
2053
|
+
{ type: "text", text: `Screenshot: ${result.path}${result.note ?? ""}` },
|
|
1829
2054
|
...(result.path ? [{ type: "resource_link", uri: `file://${result.path}`, name: basename(result.path), mimeType: result.mimeType }] : []),
|
|
1830
2055
|
],
|
|
1831
2056
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 87 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -128,7 +128,7 @@ function safeArg(x, label) {
|
|
|
128
128
|
return x;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
// ctx: { run, iosDevice, adbFlag, dumperScript }
|
|
131
|
+
// ctx: { run, iosDevice, adbFlag, dumperScript, dumperCommand }
|
|
132
132
|
export async function handleDesign(name, args, ctx) {
|
|
133
133
|
switch (name) {
|
|
134
134
|
case "design_mock_detect":
|
|
@@ -172,7 +172,8 @@ export async function handleDesign(name, args, ctx) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
const depth = Number(args.max_depth) || 12;
|
|
175
|
-
const
|
|
175
|
+
const dumper = ctx.dumperCommand ? ctx.dumperCommand() : `swift ${shq(ctx.dumperScript)}`;
|
|
176
|
+
const raw = ctx.run(`${dumper} ${depth}`, { timeout: 15000 });
|
|
176
177
|
let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
|
|
177
178
|
const elements = flattenIosAxTree(tree);
|
|
178
179
|
const rf = tree.frame || {};
|
|
@@ -541,6 +541,19 @@ ${variants.map((v, i) => variantSection(v, i, fileKey, L)).join("")}
|
|
|
541
541
|
</body></html>`;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
// The browser is closed on every path: a failed setContent/pdf used to leave a
|
|
545
|
+
// headless Chromium running for the life of the server.
|
|
546
|
+
export async function renderPdf({ html, pdfPath, chromium }) {
|
|
547
|
+
const browser = await chromium.launch();
|
|
548
|
+
try {
|
|
549
|
+
const page = await browser.newPage();
|
|
550
|
+
await page.setContent(html, { waitUntil: "networkidle" });
|
|
551
|
+
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
|
|
552
|
+
} finally {
|
|
553
|
+
await browser.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
544
557
|
export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
545
558
|
const html = renderHtml(report);
|
|
546
559
|
const out = {};
|
|
@@ -571,11 +584,8 @@ export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
|
571
584
|
let ok = false, err = "";
|
|
572
585
|
try {
|
|
573
586
|
const { chromium } = await import("playwright");
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
await page.setContent(html, { waitUntil: "networkidle" });
|
|
577
|
-
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
|
|
578
|
-
await browser.close(); ok = true;
|
|
587
|
+
await renderPdf({ html, pdfPath, chromium });
|
|
588
|
+
ok = true;
|
|
579
589
|
} catch (e) {
|
|
580
590
|
err = e.message;
|
|
581
591
|
// Fallback: use the chrome-headless-shell binary directly (no `playwright` package).
|
|
@@ -67,8 +67,18 @@ const RULE_GROUPS = {
|
|
|
67
67
|
function resolveRuleSelection(rules) {
|
|
68
68
|
if (!rules || rules === "all" || rules === "deep") return RULE_GROUPS.all();
|
|
69
69
|
if (rules === "core") return RULE_GROUPS.core();
|
|
70
|
-
// Comma-separated explicit list.
|
|
71
|
-
|
|
70
|
+
// Comma-separated explicit list. An unknown id used to select nothing, and
|
|
71
|
+
// nothing selected audited nothing and reported PASS.
|
|
72
|
+
const known = RULE_REGISTRY.map((r) => r.id);
|
|
73
|
+
const asked = String(rules).split(",").map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
const unknown = asked.filter((id) => !known.includes(id));
|
|
75
|
+
if (unknown.length > 0) {
|
|
76
|
+
throw new Error(`unknown rule id(s): ${unknown.join(", ")}. Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
if (asked.length === 0) {
|
|
79
|
+
throw new Error(`no rules selected from "${rules}". Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
return asked;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
// ---------- Public entry ---------------------------------------------------
|
|
@@ -87,6 +97,7 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
87
97
|
|
|
88
98
|
const selectedIDs = new Set(resolveRuleSelection(rules));
|
|
89
99
|
const toRun = RULE_REGISTRY.filter((r) => selectedIDs.has(r.id));
|
|
100
|
+
if (toRun.length === 0) throw new Error("no rules selected; an audit that runs nothing cannot pass");
|
|
90
101
|
|
|
91
102
|
const violations = [];
|
|
92
103
|
const ranIDs = [];
|
|
@@ -135,6 +146,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
135
146
|
info: violations.filter((v) => v.severity === "info").length,
|
|
136
147
|
total: violations.length,
|
|
137
148
|
};
|
|
149
|
+
if (ranIDs.length === 0) {
|
|
150
|
+
throw new Error(`no rule ran: ${skippedIDs.map((s) => `${s.id} (${s.reason})`).join("; ")}`);
|
|
151
|
+
}
|
|
138
152
|
const verdict = summary.error > 0 ? "FAIL" : summary.warning > 0 ? "WARN" : "PASS";
|
|
139
153
|
|
|
140
154
|
return {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { execFile } from "child_process";
|
|
27
|
-
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
|
|
27
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
|
|
28
28
|
import { join } from "path";
|
|
29
29
|
import { tmpdir } from "os";
|
|
30
30
|
|
|
@@ -227,6 +227,15 @@ export function resolveAuth(opts = {}) {
|
|
|
227
227
|
* @param {string} [opts.signingStyle] "automatic" | "manual"
|
|
228
228
|
* @returns {string} plist XML
|
|
229
229
|
*/
|
|
230
|
+
export function xmlEscape(value) {
|
|
231
|
+
return String(value ?? "")
|
|
232
|
+
.replace(/&/g, "&")
|
|
233
|
+
.replace(/</g, "<")
|
|
234
|
+
.replace(/>/g, ">")
|
|
235
|
+
.replace(/"/g, """)
|
|
236
|
+
.replace(/'/g, "'");
|
|
237
|
+
}
|
|
238
|
+
|
|
230
239
|
export function buildExportOptionsPlist(opts) {
|
|
231
240
|
const {
|
|
232
241
|
method = "app-store-connect",
|
|
@@ -236,17 +245,17 @@ export function buildExportOptionsPlist(opts) {
|
|
|
236
245
|
signingStyle,
|
|
237
246
|
} = opts;
|
|
238
247
|
|
|
239
|
-
const entries = [` <key>method</key>\n <string>${method}</string>`];
|
|
248
|
+
const entries = [` <key>method</key>\n <string>${xmlEscape(method)}</string>`];
|
|
240
249
|
entries.push(
|
|
241
250
|
` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
|
|
242
251
|
);
|
|
243
|
-
if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
|
|
252
|
+
if (teamId) entries.push(` <key>teamID</key>\n <string>${xmlEscape(teamId)}</string>`);
|
|
244
253
|
if (signingStyle) {
|
|
245
|
-
entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
|
|
254
|
+
entries.push(` <key>signingStyle</key>\n <string>${xmlEscape(signingStyle)}</string>`);
|
|
246
255
|
}
|
|
247
256
|
if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
|
|
248
257
|
const rows = Object.entries(provisioningProfiles)
|
|
249
|
-
.map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
|
|
258
|
+
.map(([bundleId, profile]) => ` <key>${xmlEscape(bundleId)}</key>\n <string>${xmlEscape(profile)}</string>`)
|
|
250
259
|
.join("\n");
|
|
251
260
|
entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
|
|
252
261
|
}
|
|
@@ -263,6 +272,36 @@ export function buildExportOptionsPlist(opts) {
|
|
|
263
272
|
].join("\n");
|
|
264
273
|
}
|
|
265
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The .ipa this export wrote: the newest one in output_dir, and only if it was
|
|
277
|
+
* written after the export started. An older .ipa left by a previous run was
|
|
278
|
+
* reported as this run's result.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} outputDir
|
|
281
|
+
* @param {number} notBefore - epoch ms; files modified earlier are stale
|
|
282
|
+
* @returns {{ipaPath: string|null, stale: string[]}}
|
|
283
|
+
*/
|
|
284
|
+
export function pickExportedIpa(outputDir, notBefore) {
|
|
285
|
+
if (!outputDir || !existsSync(outputDir)) return { ipaPath: null, stale: [] };
|
|
286
|
+
const candidates = readdirSync(outputDir)
|
|
287
|
+
.filter((f) => f.endsWith(".ipa"))
|
|
288
|
+
.map((f) => {
|
|
289
|
+
const full = join(outputDir, f);
|
|
290
|
+
try {
|
|
291
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
298
|
+
const fresh = candidates.find((c) => c.mtime >= notBefore);
|
|
299
|
+
return {
|
|
300
|
+
ipaPath: fresh ? fresh.full : null,
|
|
301
|
+
stale: candidates.filter((c) => c.mtime < notBefore).map((c) => c.full),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
266
305
|
/**
|
|
267
306
|
* Export a .xcarchive to a signed .ipa.
|
|
268
307
|
*
|
|
@@ -305,6 +344,8 @@ export async function exportIpa(opts) {
|
|
|
305
344
|
// want that.
|
|
306
345
|
if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
|
|
307
346
|
|
|
347
|
+
// 2s of slack for filesystems with coarse mtime granularity.
|
|
348
|
+
const startedAt = Date.now() - 2000;
|
|
308
349
|
const { err, stdout, stderr } = await execFileAsync("xcodebuild", argv, {
|
|
309
350
|
encoding: "utf-8",
|
|
310
351
|
timeout: timeoutSec * 1000,
|
|
@@ -317,13 +358,15 @@ export async function exportIpa(opts) {
|
|
|
317
358
|
: stdout;
|
|
318
359
|
|
|
319
360
|
const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
|
|
323
|
-
if (ipa) ipaPath = join(outputDir, ipa);
|
|
324
|
-
}
|
|
361
|
+
const picked = pickExportedIpa(outputDir, startedAt);
|
|
362
|
+
const ipaPath = picked.ipaPath || undefined;
|
|
325
363
|
// xcodebuild can exit 0 and still produce nothing useful.
|
|
326
|
-
if (!ipaPath)
|
|
364
|
+
if (!ipaPath) {
|
|
365
|
+
ok = false;
|
|
366
|
+
if (picked.stale.length > 0) {
|
|
367
|
+
errors.push(`error: no .ipa was written by this export; ${picked.stale.join(", ")} in output_dir predates this run`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
327
370
|
|
|
328
371
|
// The plist is consumed by the time xcodebuild returns. Leaving the temp dir
|
|
329
372
|
// behind would accumulate one per export for the life of the machine; the path
|
package/tools/memory/index.js
CHANGED
|
@@ -132,17 +132,32 @@ export function parseMeminfoOutput(raw) {
|
|
|
132
132
|
return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Accept both snapshot shapes: the parser's (pss, totalPssKb) and the one the
|
|
137
|
+
* android_meminfo tool emits and hands back as baseline_json (pss_kb,
|
|
138
|
+
* total_pss_kb). The diff read only the parser shape while the tool emitted
|
|
139
|
+
* the other, so mode=diff never compared anything.
|
|
140
|
+
*/
|
|
141
|
+
function normalizeSnapshot(s) {
|
|
142
|
+
if (!s || typeof s !== "object") return null;
|
|
143
|
+
const pss = s.pss && typeof s.pss === "object" ? s.pss : s.pss_kb && typeof s.pss_kb === "object" ? s.pss_kb : {};
|
|
144
|
+
const totalPssKb = typeof s.totalPssKb === "number" ? s.totalPssKb : typeof s.total_pss_kb === "number" ? s.total_pss_kb : null;
|
|
145
|
+
return { measurable: s.measurable === true, pss, totalPssKb };
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
/**
|
|
136
149
|
* Difference between two meminfo snapshots, in KB.
|
|
137
150
|
*
|
|
138
151
|
* Only keys present in both are compared; a key missing from either side is
|
|
139
152
|
* absent from the result rather than counted as zero growth.
|
|
140
153
|
*
|
|
141
|
-
* @param {object} before - parseMeminfoOutput result
|
|
142
|
-
* @param {object} after - parseMeminfoOutput result
|
|
154
|
+
* @param {object} before - parseMeminfoOutput result, or the android_meminfo snapshot payload
|
|
155
|
+
* @param {object} after - parseMeminfoOutput result, or the android_meminfo snapshot payload
|
|
143
156
|
* @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
|
|
144
157
|
*/
|
|
145
|
-
export function diffMeminfo(
|
|
158
|
+
export function diffMeminfo(beforeRaw, afterRaw) {
|
|
159
|
+
const before = normalizeSnapshot(beforeRaw);
|
|
160
|
+
const after = normalizeSnapshot(afterRaw);
|
|
146
161
|
if (!before?.measurable || !after?.measurable) {
|
|
147
162
|
return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
|
|
148
163
|
}
|
package/tools/offload/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// outputSchema answers with JSON the host parses as structuredContent, and
|
|
16
16
|
// replacing that with a summary would break the parse.
|
|
17
17
|
|
|
18
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync,
|
|
18
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync } from "fs";
|
|
19
19
|
import { join, resolve, sep } from "path";
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
|
|
@@ -31,20 +31,24 @@ const TAIL_LINES = 20;
|
|
|
31
31
|
export const OFFLOAD_KEEP_FILES = 50;
|
|
32
32
|
export const OFFLOAD_KEEP_DAYS = 7;
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
// The server's own scratch directory (screenshots, UI dumps, push payloads,
|
|
35
|
+
// build logs, .xcresult bundles) gets the same treatment with a wider count,
|
|
36
|
+
// since a single design audit writes 100+ captures.
|
|
37
|
+
export const WORK_KEEP_ENTRIES = 200;
|
|
38
|
+
export const WORK_KEEP_DAYS = 7;
|
|
39
|
+
|
|
40
|
+
// `keep` names the file(s) the caller was just promised. They are never deleted,
|
|
41
|
+
// whatever the retention numbers say: the returned text promises that path to
|
|
42
|
+
// the caller, and this module exists precisely because losing the payload is the
|
|
43
|
+
// failure mode. A keepFiles of 0 must bound the directory, not break the answer.
|
|
44
|
+
function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
|
|
45
|
+
const keepList = Array.isArray(keep) ? keep : keep ? [keep] : [];
|
|
46
|
+
const keepSet = new Set(keepList.filter((k) => typeof k === "string").map((k) => resolve(k)));
|
|
43
47
|
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
|
44
48
|
let removed = 0;
|
|
45
49
|
try {
|
|
46
50
|
const entries = readdirSync(dir)
|
|
47
|
-
.filter(
|
|
51
|
+
.filter(match)
|
|
48
52
|
.map((n) => {
|
|
49
53
|
const full = join(dir, n);
|
|
50
54
|
try {
|
|
@@ -57,12 +61,12 @@ export function pruneOffloadDir(dir, opts = {}) {
|
|
|
57
61
|
.sort((a, b) => b.mtime - a.mtime);
|
|
58
62
|
|
|
59
63
|
for (let i = 0; i < entries.length; i++) {
|
|
60
|
-
if (
|
|
64
|
+
if (keepSet.has(resolve(entries[i].full))) continue;
|
|
61
65
|
const tooOld = entries[i].mtime < cutoff;
|
|
62
66
|
const tooMany = i >= keepFiles;
|
|
63
67
|
if (!tooOld && !tooMany) continue;
|
|
64
68
|
try {
|
|
65
|
-
|
|
69
|
+
rmSync(entries[i].full, { recursive: true, force: true });
|
|
66
70
|
removed++;
|
|
67
71
|
} catch {
|
|
68
72
|
// A file another process holds open is skipped, not fatal.
|
|
@@ -74,6 +78,28 @@ export function pruneOffloadDir(dir, opts = {}) {
|
|
|
74
78
|
return removed;
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
export function pruneOffloadDir(dir, opts = {}) {
|
|
82
|
+
return pruneEntries(dir, {
|
|
83
|
+
keepFiles: opts.keepFiles ?? OFFLOAD_KEEP_FILES,
|
|
84
|
+
keepDays: opts.keepDays ?? OFFLOAD_KEEP_DAYS,
|
|
85
|
+
now: opts.now ?? Date.now(),
|
|
86
|
+
keep: opts.keep,
|
|
87
|
+
match: (n) => n.endsWith(".txt"),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every entry counts here, directories included: an .xcresult bundle is a
|
|
92
|
+
// directory and was the largest thing nothing ever removed.
|
|
93
|
+
export function pruneWorkDir(dir, opts = {}) {
|
|
94
|
+
return pruneEntries(dir, {
|
|
95
|
+
keepFiles: opts.keepFiles ?? WORK_KEEP_ENTRIES,
|
|
96
|
+
keepDays: opts.keepDays ?? WORK_KEEP_DAYS,
|
|
97
|
+
now: opts.now ?? Date.now(),
|
|
98
|
+
keep: opts.keep,
|
|
99
|
+
match: () => true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
let lastOffload = null;
|
|
78
104
|
|
|
79
105
|
export function lastOffloadRecord() {
|