serve-sim 0.1.20 → 0.1.21
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 +451 -13
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
|
|
@@ -637,9 +1059,29 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
637
1059
|
'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
|
|
638
1060
|
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
639
1061
|
|
|
640
|
-
// e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
|
|
641
|
-
const FG_RE = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
|
|
642
1062
|
let lastBundle = "";
|
|
1063
|
+
let hasEmitted = false;
|
|
1064
|
+
let closed = false;
|
|
1065
|
+
const emitApp = async (bundleId: string, pid?: number) => {
|
|
1066
|
+
if (!isUserFacingBundle(bundleId)) return;
|
|
1067
|
+
if (bundleId === lastBundle) return;
|
|
1068
|
+
lastBundle = bundleId;
|
|
1069
|
+
hasEmitted = true;
|
|
1070
|
+
const isReactNative = await detectReactNative(udid, bundleId);
|
|
1071
|
+
if (!closed) {
|
|
1072
|
+
res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
// The seed loses to any live log event — a SpringBoard log that fires
|
|
1077
|
+
// while the AX call is in flight is fresher than the AX snapshot.
|
|
1078
|
+
detectCurrentForegroundApp(udid, state.url).then((app) => {
|
|
1079
|
+
if (!app || closed || hasEmitted) return;
|
|
1080
|
+
lastBundle = app.bundleId;
|
|
1081
|
+
hasEmitted = true;
|
|
1082
|
+
res.write("data: " + JSON.stringify(app) + "\n\n");
|
|
1083
|
+
});
|
|
1084
|
+
|
|
643
1085
|
let buf = "";
|
|
644
1086
|
child.stdout!.on("data", (chunk: Buffer) => {
|
|
645
1087
|
buf += chunk.toString();
|
|
@@ -650,21 +1092,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
650
1092
|
if (!line) continue;
|
|
651
1093
|
let msg: string;
|
|
652
1094
|
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
|
-
});
|
|
1095
|
+
const event = parseForegroundAppLogMessage(msg);
|
|
1096
|
+
if (!event) continue;
|
|
1097
|
+
emitApp(event.bundleId, event.pid);
|
|
663
1098
|
}
|
|
664
1099
|
});
|
|
665
1100
|
|
|
666
1101
|
child.on("close", () => res.end());
|
|
667
|
-
req.on("close", () =>
|
|
1102
|
+
req.on("close", () => {
|
|
1103
|
+
closed = true;
|
|
1104
|
+
child.kill();
|
|
1105
|
+
});
|
|
668
1106
|
return;
|
|
669
1107
|
}
|
|
670
1108
|
|