serve-sim 0.1.20 → 0.1.22
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/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +26 -23
- package/dist/serve-sim.js +30 -27
- package/package.json +1 -1
- package/src/middleware.ts +466 -14
package/package.json
CHANGED
package/src/middleware.ts
CHANGED
|
@@ -68,6 +68,13 @@ function isUserFacingBundle(bundleId: string): boolean {
|
|
|
68
68
|
return !NON_UI_BUNDLE_RE.test(bundleId);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export function parseForegroundAppLogMessage(message: string): { bundleId: string; pid: number } | null {
|
|
72
|
+
// e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
|
|
73
|
+
const match = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/.exec(message);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
return { bundleId: match[1]!, pid: parseInt(match[2]!, 10) };
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
|
|
72
79
|
if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true);
|
|
73
80
|
return new Promise((resolve) => {
|
|
@@ -85,6 +92,84 @@ function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
|
|
|
85
92
|
});
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
type InstalledApp = {
|
|
96
|
+
CFBundleDisplayName?: string;
|
|
97
|
+
CFBundleExecutable?: string;
|
|
98
|
+
CFBundleIdentifier?: string;
|
|
99
|
+
CFBundleName?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function normalizeAppName(name: string): string {
|
|
103
|
+
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function matchInstalledAppByDisplayName(
|
|
107
|
+
apps: Record<string, InstalledApp>,
|
|
108
|
+
displayName: string,
|
|
109
|
+
): string | null {
|
|
110
|
+
const wanted = normalizeAppName(displayName);
|
|
111
|
+
if (!wanted) return null;
|
|
112
|
+
|
|
113
|
+
for (const [bundleId, app] of Object.entries(apps)) {
|
|
114
|
+
const names = [
|
|
115
|
+
app.CFBundleDisplayName,
|
|
116
|
+
app.CFBundleName,
|
|
117
|
+
app.CFBundleExecutable,
|
|
118
|
+
].filter((value): value is string => typeof value === "string");
|
|
119
|
+
if (names.some((name) => normalizeAppName(name) === wanted)) {
|
|
120
|
+
return app.CFBundleIdentifier || bundleId;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function shellQuote(value: string): string {
|
|
127
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function installedAppsForDevice(udid: string): Promise<Record<string, InstalledApp>> {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
exec(
|
|
133
|
+
`xcrun simctl listapps ${shellQuote(udid)} | plutil -convert json -o - -`,
|
|
134
|
+
{ timeout: 3_000 },
|
|
135
|
+
(err, stdout) => {
|
|
136
|
+
if (err || !stdout) return resolve({});
|
|
137
|
+
try {
|
|
138
|
+
resolve(JSON.parse(stdout) as Record<string, InstalledApp>);
|
|
139
|
+
} catch {
|
|
140
|
+
resolve({});
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function detectCurrentForegroundApp(
|
|
148
|
+
udid: string,
|
|
149
|
+
stateUrl: string,
|
|
150
|
+
): Promise<{ bundleId: string; isReactNative: boolean } | null> {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), 2_000);
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(`${stateUrl}/ax`, { signal: controller.signal });
|
|
155
|
+
if (!res.ok) return null;
|
|
156
|
+
const tree = await res.json() as Array<{ AXLabel?: unknown }>;
|
|
157
|
+
const displayName = tree?.[0]?.AXLabel;
|
|
158
|
+
if (typeof displayName !== "string") return null;
|
|
159
|
+
|
|
160
|
+
const bundleId = matchInstalledAppByDisplayName(
|
|
161
|
+
await installedAppsForDevice(udid),
|
|
162
|
+
displayName,
|
|
163
|
+
);
|
|
164
|
+
if (!bundleId || !isUserFacingBundle(bundleId)) return null;
|
|
165
|
+
return { bundleId, isReactNative: await detectReactNative(udid, bundleId) };
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
88
173
|
// Cache simctl's booted-device set briefly so per-request cost stays bounded.
|
|
89
174
|
// The middleware runs inside the user's dev server (Metro etc.) and
|
|
90
175
|
// readServeSimStates() is called on every /api and every page load.
|
|
@@ -314,6 +399,186 @@ function loadHtml(): string {
|
|
|
314
399
|
return _html;
|
|
315
400
|
}
|
|
316
401
|
|
|
402
|
+
interface SimctlDevice {
|
|
403
|
+
udid: string;
|
|
404
|
+
name: string;
|
|
405
|
+
state: string;
|
|
406
|
+
isAvailable?: boolean;
|
|
407
|
+
runtime: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function listAllSimulators(): SimctlDevice[] {
|
|
411
|
+
try {
|
|
412
|
+
const output = execSync("xcrun simctl list devices -j", {
|
|
413
|
+
encoding: "utf-8",
|
|
414
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
415
|
+
timeout: 3_000,
|
|
416
|
+
});
|
|
417
|
+
const data = JSON.parse(output) as {
|
|
418
|
+
devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
|
|
419
|
+
};
|
|
420
|
+
const out: SimctlDevice[] = [];
|
|
421
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
422
|
+
// Only iOS (skip watchOS / tvOS / visionOS for the grid MVP — the helper
|
|
423
|
+
// is iOS-focused and the bezel/touch model assumes a phone-shaped device).
|
|
424
|
+
if (!/SimRuntime\.iOS-/i.test(runtime)) continue;
|
|
425
|
+
for (const d of devices) {
|
|
426
|
+
if (d.isAvailable === false) continue;
|
|
427
|
+
out.push({ ...d, runtime: runtime.replace(/^.*SimRuntime\./, "") });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
} catch {
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function deviceNameFor(udid: string): string | null {
|
|
437
|
+
return listAllSimulators().find((d) => d.udid === udid)?.name ?? null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Default per-simulator footprint when we have no running sim to measure
|
|
441
|
+
// from — a fresh booted iOS sim with one app launched typically sits in
|
|
442
|
+
// the 1.2–1.8 GB range. Used as a fallback only.
|
|
443
|
+
const DEFAULT_PER_SIM_BYTES = 1.5 * 1024 * 1024 * 1024;
|
|
444
|
+
|
|
445
|
+
interface MemoryReport {
|
|
446
|
+
totalBytes: number;
|
|
447
|
+
availableBytes: number;
|
|
448
|
+
runningSimulators: number;
|
|
449
|
+
perSimAvgBytes: number;
|
|
450
|
+
perSimSource: "measured" | "estimated";
|
|
451
|
+
estimatedAdditional: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readSystemMemory(): { totalBytes: number; availableBytes: number } {
|
|
455
|
+
try {
|
|
456
|
+
const totalBytes = Number(
|
|
457
|
+
execSync("sysctl -n hw.memsize", {
|
|
458
|
+
encoding: "utf-8",
|
|
459
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
460
|
+
timeout: 1500,
|
|
461
|
+
}).trim(),
|
|
462
|
+
);
|
|
463
|
+
const pageSize = Number(
|
|
464
|
+
execSync("sysctl -n hw.pagesize", {
|
|
465
|
+
encoding: "utf-8",
|
|
466
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
467
|
+
timeout: 1500,
|
|
468
|
+
}).trim(),
|
|
469
|
+
);
|
|
470
|
+
const vmStat = execSync("vm_stat", {
|
|
471
|
+
encoding: "utf-8",
|
|
472
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
473
|
+
timeout: 1500,
|
|
474
|
+
});
|
|
475
|
+
const pages = (re: RegExp) => {
|
|
476
|
+
const m = vmStat.match(re);
|
|
477
|
+
return m ? Number(m[1]) : 0;
|
|
478
|
+
};
|
|
479
|
+
// "Available" mirrors what Activity Monitor treats as reclaimable: free
|
|
480
|
+
// + inactive + speculative pages. Excludes wired and active.
|
|
481
|
+
const availablePages =
|
|
482
|
+
pages(/Pages free:\s+(\d+)/) +
|
|
483
|
+
pages(/Pages inactive:\s+(\d+)/) +
|
|
484
|
+
pages(/Pages speculative:\s+(\d+)/);
|
|
485
|
+
return {
|
|
486
|
+
totalBytes: Number.isFinite(totalBytes) ? totalBytes : 0,
|
|
487
|
+
availableBytes: availablePages * (Number.isFinite(pageSize) ? pageSize : 4096),
|
|
488
|
+
};
|
|
489
|
+
} catch {
|
|
490
|
+
return { totalBytes: 0, availableBytes: 0 };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Sum RSS across every process whose argv path includes a CoreSimulator
|
|
495
|
+
// device directory. Groups by UDID so we get a real per-sim footprint that
|
|
496
|
+
// covers launchd_sim plus all child processes the runtime spawns.
|
|
497
|
+
function readSimulatorMemoryUsage(): { perUdid: Record<string, number>; totalBytes: number } {
|
|
498
|
+
try {
|
|
499
|
+
const output = execSync("ps -axo rss=,args=", {
|
|
500
|
+
encoding: "utf-8",
|
|
501
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
502
|
+
timeout: 3000,
|
|
503
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
504
|
+
});
|
|
505
|
+
const perUdid: Record<string, number> = {};
|
|
506
|
+
let totalBytes = 0;
|
|
507
|
+
const re = /\/Devices\/([0-9A-F-]{36})\//i;
|
|
508
|
+
for (const raw of output.split("\n")) {
|
|
509
|
+
const line = raw.trimStart();
|
|
510
|
+
if (!line) continue;
|
|
511
|
+
const m = re.exec(line);
|
|
512
|
+
if (!m) continue;
|
|
513
|
+
const rssKb = Number(line.split(/\s+/, 1)[0]);
|
|
514
|
+
if (!Number.isFinite(rssKb)) continue;
|
|
515
|
+
const bytes = rssKb * 1024;
|
|
516
|
+
const udid = m[1].toUpperCase();
|
|
517
|
+
perUdid[udid] = (perUdid[udid] ?? 0) + bytes;
|
|
518
|
+
totalBytes += bytes;
|
|
519
|
+
}
|
|
520
|
+
return { perUdid, totalBytes };
|
|
521
|
+
} catch {
|
|
522
|
+
return { perUdid: {}, totalBytes: 0 };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildMemoryReport(): MemoryReport {
|
|
527
|
+
const { totalBytes, availableBytes } = readSystemMemory();
|
|
528
|
+
const usage = readSimulatorMemoryUsage();
|
|
529
|
+
const runningSimulators = Object.keys(usage.perUdid).length;
|
|
530
|
+
const measuredAvg = runningSimulators > 0
|
|
531
|
+
? usage.totalBytes / runningSimulators
|
|
532
|
+
: 0;
|
|
533
|
+
// Below ~256MB, the measurement is almost certainly catching a sim mid-boot
|
|
534
|
+
// before its app processes are resident — fall back to the default so we
|
|
535
|
+
// don't over-promise capacity.
|
|
536
|
+
const perSimSource: MemoryReport["perSimSource"] =
|
|
537
|
+
measuredAvg >= 256 * 1024 * 1024 ? "measured" : "estimated";
|
|
538
|
+
const perSimAvgBytes =
|
|
539
|
+
perSimSource === "measured" ? measuredAvg : DEFAULT_PER_SIM_BYTES;
|
|
540
|
+
const estimatedAdditional = perSimAvgBytes > 0
|
|
541
|
+
? Math.max(0, Math.floor(availableBytes / perSimAvgBytes))
|
|
542
|
+
: 0;
|
|
543
|
+
return {
|
|
544
|
+
totalBytes,
|
|
545
|
+
availableBytes,
|
|
546
|
+
runningSimulators,
|
|
547
|
+
perSimAvgBytes,
|
|
548
|
+
perSimSource,
|
|
549
|
+
estimatedAdditional,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Locate the `serve-sim` CLI binary so the grid can spawn helpers via
|
|
555
|
+
* `serve-sim --detach <udid>`. Tries, in order:
|
|
556
|
+
* 1. argv[0] if it ends in `serve-sim` (we're running inside the
|
|
557
|
+
* compiled standalone binary, which IS the CLI)
|
|
558
|
+
* 2. `serve-sim` on PATH (npm-installed / bun-installed CLI)
|
|
559
|
+
* Returns the resolved command + args ready for spawn.
|
|
560
|
+
*/
|
|
561
|
+
function resolveServeSimCommand(): { command: string; baseArgs: string[] } | null {
|
|
562
|
+
// 1. Compiled standalone binary: argv[0] is the serve-sim binary itself.
|
|
563
|
+
if (process.argv[0] && /(^|\/)serve-sim$/.test(process.argv[0])) {
|
|
564
|
+
return { command: process.argv[0], baseArgs: [] };
|
|
565
|
+
}
|
|
566
|
+
// 2. Running the JS bundle directly: `node /path/to/serve-sim.js`.
|
|
567
|
+
if (process.argv[1] && /(^|\/)serve-sim\.js$/.test(process.argv[1])) {
|
|
568
|
+
return { command: process.argv[0]!, baseArgs: [process.argv[1]!] };
|
|
569
|
+
}
|
|
570
|
+
// 3. Global install: serve-sim is on PATH.
|
|
571
|
+
try {
|
|
572
|
+
const path = execSync("command -v serve-sim", {
|
|
573
|
+
encoding: "utf-8",
|
|
574
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
575
|
+
timeout: 1_500,
|
|
576
|
+
}).trim();
|
|
577
|
+
if (path) return { command: path, baseArgs: [] };
|
|
578
|
+
} catch {}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
317
582
|
export interface SimMiddlewareOptions {
|
|
318
583
|
/** Base path to serve the preview at. Default: "/.sim" */
|
|
319
584
|
basePath?: string;
|
|
@@ -384,6 +649,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
384
649
|
// Pass real serve-sim URLs directly. The client parses the MJPEG
|
|
385
650
|
// stream via fetch() (CORS is fine — serve-sim sends Access-Control-Allow-Origin: *)
|
|
386
651
|
// and connects to the WS directly (WS has no CORS).
|
|
652
|
+
const gridApiBase = (base === "" ? "" : base) + "/grid/api";
|
|
387
653
|
const config = JSON.stringify({
|
|
388
654
|
...state,
|
|
389
655
|
basePath: base,
|
|
@@ -391,6 +657,11 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
391
657
|
appStateEndpoint: endpoint(base, "/appstate", state.device),
|
|
392
658
|
axEndpoint: endpoint(base, "/ax", state.device),
|
|
393
659
|
devtoolsEndpoint: endpoint(base, "/devtools", state.device),
|
|
660
|
+
gridApiEndpoint: gridApiBase,
|
|
661
|
+
gridStartEndpoint: gridApiBase + "/start",
|
|
662
|
+
gridShutdownEndpoint: gridApiBase + "/shutdown",
|
|
663
|
+
gridMemoryEndpoint: gridApiBase + "/memory",
|
|
664
|
+
previewEndpoint: base === "" ? "/" : base,
|
|
394
665
|
});
|
|
395
666
|
const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
|
|
396
667
|
html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
|
|
@@ -404,6 +675,157 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
404
675
|
return;
|
|
405
676
|
}
|
|
406
677
|
|
|
678
|
+
// Memory capacity estimate: how much room is left to boot more sims.
|
|
679
|
+
if (url === base + "/grid/api/memory") {
|
|
680
|
+
res.writeHead(200, {
|
|
681
|
+
"Content-Type": "application/json",
|
|
682
|
+
"Cache-Control": "no-store",
|
|
683
|
+
});
|
|
684
|
+
res.end(JSON.stringify(buildMemoryReport()));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Grid JSON: every iOS simulator, annotated with running helper info if any.
|
|
689
|
+
if (url === base + "/grid/api") {
|
|
690
|
+
const states = readServeSimStates();
|
|
691
|
+
const helperByUdid = new Map(states.map((s) => [s.device, s] as const));
|
|
692
|
+
const sims = listAllSimulators();
|
|
693
|
+
const devices = sims.map((d) => {
|
|
694
|
+
const helper = helperByUdid.get(d.udid);
|
|
695
|
+
return {
|
|
696
|
+
device: d.udid,
|
|
697
|
+
name: d.name,
|
|
698
|
+
runtime: d.runtime,
|
|
699
|
+
state: d.state,
|
|
700
|
+
helper: helper
|
|
701
|
+
? {
|
|
702
|
+
port: helper.port,
|
|
703
|
+
url: helper.url,
|
|
704
|
+
streamUrl: helper.streamUrl,
|
|
705
|
+
wsUrl: helper.wsUrl,
|
|
706
|
+
}
|
|
707
|
+
: null,
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
// Stable order: family (iPhone, iPad, Watch, TV, Vision, other) →
|
|
711
|
+
// state (helper > booted > shutdown) → alpha. Keeps the most
|
|
712
|
+
// commonly used devices visible without scrolling.
|
|
713
|
+
const familyRank = (name: string): number => {
|
|
714
|
+
if (/iphone/i.test(name)) return 0;
|
|
715
|
+
if (/ipad/i.test(name)) return 1;
|
|
716
|
+
if (/watch/i.test(name)) return 2;
|
|
717
|
+
if (/(apple\s*tv|^tv\b)/i.test(name)) return 3;
|
|
718
|
+
if (/vision|reality/i.test(name)) return 4;
|
|
719
|
+
return 5;
|
|
720
|
+
};
|
|
721
|
+
const stateRank = (x: typeof devices[number]) =>
|
|
722
|
+
x.helper ? 0 : x.state === "Booted" ? 1 : 2;
|
|
723
|
+
devices.sort((a, b) =>
|
|
724
|
+
familyRank(a.name) - familyRank(b.name) ||
|
|
725
|
+
stateRank(a) - stateRank(b) ||
|
|
726
|
+
a.name.localeCompare(b.name),
|
|
727
|
+
);
|
|
728
|
+
res.writeHead(200, {
|
|
729
|
+
"Content-Type": "application/json",
|
|
730
|
+
"Cache-Control": "no-store",
|
|
731
|
+
});
|
|
732
|
+
res.end(JSON.stringify({ devices }));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Shutdown a booted simulator. Any running helper for the device is reaped
|
|
737
|
+
// by readServeSimStates() on the next /grid/api poll (it kills helpers
|
|
738
|
+
// whose backing simulator is no longer in the booted set).
|
|
739
|
+
if (url === base + "/grid/api/shutdown" && req.method === "POST") {
|
|
740
|
+
let body = "";
|
|
741
|
+
req.on("data", (chunk: Buffer | string) => {
|
|
742
|
+
body += typeof chunk === "string" ? chunk : chunk.toString();
|
|
743
|
+
});
|
|
744
|
+
req.on("end", () => {
|
|
745
|
+
let udid = "";
|
|
746
|
+
try { udid = JSON.parse(body).udid ?? ""; } catch {}
|
|
747
|
+
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
|
|
748
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
749
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Drop the snapshot so the next /grid/api call re-queries simctl
|
|
753
|
+
// and prunes any helper bound to this now-shutdown device.
|
|
754
|
+
bootedSnapshot = { at: 0, booted: null };
|
|
755
|
+
execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
|
|
756
|
+
if (err) {
|
|
757
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
758
|
+
res.end(JSON.stringify({
|
|
759
|
+
ok: false,
|
|
760
|
+
error: stderr?.toString().trim() || err.message,
|
|
761
|
+
}));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
765
|
+
res.end(JSON.stringify({ ok: true }));
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Spawn a serve-sim helper (auto-boots if needed).
|
|
772
|
+
if (url === base + "/grid/api/start" && req.method === "POST") {
|
|
773
|
+
let body = "";
|
|
774
|
+
req.on("data", (chunk: Buffer | string) => {
|
|
775
|
+
body += typeof chunk === "string" ? chunk : chunk.toString();
|
|
776
|
+
});
|
|
777
|
+
req.on("end", () => {
|
|
778
|
+
let udid = "";
|
|
779
|
+
try { udid = JSON.parse(body).udid ?? ""; } catch {}
|
|
780
|
+
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
|
|
781
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
782
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const resolved = resolveServeSimCommand();
|
|
786
|
+
if (!resolved) {
|
|
787
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
788
|
+
res.end(JSON.stringify({
|
|
789
|
+
ok: false,
|
|
790
|
+
error: "serve-sim CLI not found in PATH. Install it (npm i -g serve-sim) and retry.",
|
|
791
|
+
}));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const child = spawn(
|
|
795
|
+
resolved.command,
|
|
796
|
+
[...resolved.baseArgs, "--detach", udid],
|
|
797
|
+
{ stdio: ["ignore", "pipe", "pipe"], detached: false },
|
|
798
|
+
);
|
|
799
|
+
let stdout = "";
|
|
800
|
+
let stderr = "";
|
|
801
|
+
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString(); });
|
|
802
|
+
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
|
|
803
|
+
// A cold iOS simulator can take 60-90s to reach `bootstatus -b`
|
|
804
|
+
// readiness; the prior 60s ceiling was killing serve-sim mid-boot
|
|
805
|
+
// and the helper never got a chance to spawn, so the click ended
|
|
806
|
+
// with an error and no state file. 3 minutes is a comfortable
|
|
807
|
+
// upper bound that covers slow first-boots without leaving a
|
|
808
|
+
// wedged child around indefinitely.
|
|
809
|
+
const timer = setTimeout(() => {
|
|
810
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
811
|
+
}, 180_000);
|
|
812
|
+
child.on("close", (code) => {
|
|
813
|
+
clearTimeout(timer);
|
|
814
|
+
if (code === 0) {
|
|
815
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
816
|
+
res.end(JSON.stringify({ ok: true, stdout: stdout.trim() }));
|
|
817
|
+
} else {
|
|
818
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
819
|
+
res.end(JSON.stringify({
|
|
820
|
+
ok: false,
|
|
821
|
+
error: stderr.trim() || stdout.trim() || `serve-sim exited with code ${code}`,
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
407
829
|
// JSON API: start the inspect-webkit CDP bridge and list WebKit targets
|
|
408
830
|
// for the selected simulator. The bridge itself serves /json/list and
|
|
409
831
|
// /devtools/page/:id on localhost; the preview adds iframe-safe frontend
|
|
@@ -629,6 +1051,31 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
629
1051
|
});
|
|
630
1052
|
res.write(":\n\n");
|
|
631
1053
|
|
|
1054
|
+
// Bootstrap: SpringBoard's log feed is edge-triggered, so a fresh
|
|
1055
|
+
// subscriber would otherwise see nothing until the user re-foregrounds
|
|
1056
|
+
// an app (the bug: tools couldn't reconnect after a page reload). Ask
|
|
1057
|
+
// the helper's AX bridge for the current frontmost app via
|
|
1058
|
+
// `proc_pidpath`+Info.plist resolution and emit it before tailing.
|
|
1059
|
+
let lastBundle = "";
|
|
1060
|
+
void (async () => {
|
|
1061
|
+
try {
|
|
1062
|
+
const ctrl = new AbortController();
|
|
1063
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
1064
|
+
const r = await fetch(`http://127.0.0.1:${state.port}/foreground`, { signal: ctrl.signal });
|
|
1065
|
+
clearTimeout(timer);
|
|
1066
|
+
if (!r.ok) return;
|
|
1067
|
+
const info = await r.json() as { bundleId?: string; pid?: number };
|
|
1068
|
+
if (!info.bundleId || !isUserFacingBundle(info.bundleId)) return;
|
|
1069
|
+
if (res.writableEnded) return;
|
|
1070
|
+
lastBundle = info.bundleId;
|
|
1071
|
+
const isReactNative = await detectReactNative(udid, info.bundleId);
|
|
1072
|
+
if (res.writableEnded) return;
|
|
1073
|
+
res.write("data: " + JSON.stringify({ bundleId: info.bundleId, pid: info.pid, isReactNative }) + "\n\n");
|
|
1074
|
+
} catch {
|
|
1075
|
+
// Helper may be coming up — log tail will fill in once anything moves.
|
|
1076
|
+
}
|
|
1077
|
+
})();
|
|
1078
|
+
|
|
632
1079
|
const child: ChildProcess = spawn("xcrun", [
|
|
633
1080
|
"simctl", "spawn", udid, "log", "stream",
|
|
634
1081
|
"--style", "ndjson",
|
|
@@ -637,9 +1084,18 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
637
1084
|
'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
|
|
638
1085
|
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
639
1086
|
|
|
640
|
-
|
|
641
|
-
const
|
|
642
|
-
|
|
1087
|
+
let closed = false;
|
|
1088
|
+
const emitApp = async (bundleId: string, pid?: number) => {
|
|
1089
|
+
if (!isUserFacingBundle(bundleId)) return;
|
|
1090
|
+
if (bundleId === lastBundle) return;
|
|
1091
|
+
lastBundle = bundleId;
|
|
1092
|
+
const isReactNative = await detectReactNative(udid, bundleId);
|
|
1093
|
+
if (!closed) {
|
|
1094
|
+
res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
|
|
643
1099
|
let buf = "";
|
|
644
1100
|
child.stdout!.on("data", (chunk: Buffer) => {
|
|
645
1101
|
buf += chunk.toString();
|
|
@@ -650,21 +1106,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
650
1106
|
if (!line) continue;
|
|
651
1107
|
let msg: string;
|
|
652
1108
|
try { msg = JSON.parse(line).eventMessage ?? ""; } catch { continue; }
|
|
653
|
-
const
|
|
654
|
-
if (!
|
|
655
|
-
|
|
656
|
-
const pid = parseInt(m[2]!, 10);
|
|
657
|
-
if (!isUserFacingBundle(bundleId)) continue;
|
|
658
|
-
if (bundleId === lastBundle) continue;
|
|
659
|
-
lastBundle = bundleId;
|
|
660
|
-
detectReactNative(udid, bundleId).then((isReactNative) => {
|
|
661
|
-
res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
|
|
662
|
-
});
|
|
1109
|
+
const event = parseForegroundAppLogMessage(msg);
|
|
1110
|
+
if (!event) continue;
|
|
1111
|
+
emitApp(event.bundleId, event.pid);
|
|
663
1112
|
}
|
|
664
1113
|
});
|
|
665
1114
|
|
|
666
1115
|
child.on("close", () => res.end());
|
|
667
|
-
req.on("close", () =>
|
|
1116
|
+
req.on("close", () => {
|
|
1117
|
+
closed = true;
|
|
1118
|
+
child.kill();
|
|
1119
|
+
});
|
|
668
1120
|
return;
|
|
669
1121
|
}
|
|
670
1122
|
|