serve-sim 0.1.49-beta.141.1 → 0.1.49
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/README.md +11 -0
- package/Sources/SimCameraInjector/SimCamFakes.m +12 -0
- package/Sources/SimCameraInjector/SimCamMovieOutput.h +17 -0
- package/Sources/SimCameraInjector/SimCamMovieOutput.m +211 -0
- package/Sources/SimCameraInjector/SimCamMovieRecorder.h +30 -0
- package/Sources/SimCameraInjector/SimCamMovieRecorder.m +270 -0
- package/Sources/SimCameraInjector/SimCamSwizzles.h +11 -0
- package/Sources/SimCameraInjector/SimCamSwizzles.m +211 -64
- package/Sources/SimCameraInjector/build.sh +2 -0
- package/Sources/SimNative/DuoHIDBridge.swift +2 -1
- package/Sources/SimNative/FrameCapture.swift +11 -3
- package/Sources/SimNative/HIDInjector.swift +1 -1
- package/Sources/SimNative/sim-module.swift +5 -0
- package/Sources/SimNativeSupport/DeviceSet.swift +34 -0
- package/Tests/SimNativeSupportTests/DeviceSetTests.swift +57 -0
- package/dist/middleware.js +49 -49
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +83 -82
- package/dist/simcam/libSimCameraInjector.dylib +0 -0
- package/package.json +1 -1
- package/src/device-hub-input.ts +4 -3
- package/src/device.ts +15 -3
- package/src/doctor.ts +2 -1
- package/src/duo-state.ts +4 -3
- package/src/event-log.ts +4 -2
- package/src/helper-lifecycle.ts +6 -1
- package/src/host-rpc.ts +9 -8
- package/src/middleware.ts +22 -14
- package/src/native.ts +8 -1
- package/src/simctl.ts +83 -2
- package/src/state.ts +9 -0
- package/src/ui-settings.ts +3 -2
|
Binary file
|
package/package.json
CHANGED
package/src/device-hub-input.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { simctlArguments } from "./simctl";
|
|
3
4
|
|
|
4
5
|
const exec = promisify(execFile);
|
|
5
6
|
type Run = (args: string[]) => Promise<string>;
|
|
@@ -13,7 +14,7 @@ export function isDeviceHubInputShadowed(output: string): boolean {
|
|
|
13
14
|
/** Warns when shadowed. Resolves the guest flag, or undefined when the runtime does not publish it. */
|
|
14
15
|
export async function warnDeviceHubInput(udid: string, invoke: Run = run): Promise<boolean | undefined> {
|
|
15
16
|
try {
|
|
16
|
-
const shadowed = isDeviceHubInputShadowed(await invoke(["
|
|
17
|
+
const shadowed = isDeviceHubInputShadowed(await invoke(simctlArguments(["spawn", udid, "notifyutil", "-g", key])));
|
|
17
18
|
if (shadowed) {
|
|
18
19
|
console.warn(`[hid] Device Hub has disconnected legacy input. Run serve-sim repair-input -d ${udid} before using touch or keyboard. Repair restarts SpringBoard and closes running apps.`);
|
|
19
20
|
}
|
|
@@ -24,7 +25,7 @@ export async function warnDeviceHubInput(udid: string, invoke: Run = run): Promi
|
|
|
24
25
|
/** Raw guest flag for diagnostics: true/false, or null when unreadable. */
|
|
25
26
|
export async function readDeviceHubInputShadowed(udid: string, invoke: Run = run): Promise<boolean | null> {
|
|
26
27
|
try {
|
|
27
|
-
return isDeviceHubInputShadowed(await invoke(["
|
|
28
|
+
return isDeviceHubInputShadowed(await invoke(simctlArguments(["spawn", udid, "notifyutil", "-g", key])));
|
|
28
29
|
} catch {
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
@@ -32,7 +33,7 @@ export async function readDeviceHubInputShadowed(udid: string, invoke: Run = run
|
|
|
32
33
|
|
|
33
34
|
/** Explicit recovery only: restarting backboardd also terminates running apps. */
|
|
34
35
|
export async function repairDeviceHubInput(udid: string, invoke: Run = run): Promise<boolean> {
|
|
35
|
-
const args = ["
|
|
36
|
+
const args = simctlArguments(["spawn", udid]);
|
|
36
37
|
if (!isDeviceHubInputShadowed(await invoke([...args, "notifyutil", "-g", key]))) return false;
|
|
37
38
|
// Order is essential: the replacement backboardd must start with state zero.
|
|
38
39
|
await invoke([...args, "notifyutil", "-s", key, "0"]);
|
package/src/device.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { simctlArguments } from "./simctl";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* UDID of a booted simulator, or null if none is booted. Prefers an iOS device
|
|
@@ -7,7 +8,7 @@ import { execSync } from "child_process";
|
|
|
7
8
|
*/
|
|
8
9
|
export function findBootedDevice(): string | null {
|
|
9
10
|
try {
|
|
10
|
-
const output =
|
|
11
|
+
const output = execFileSync("xcrun", simctlArguments(["list", "devices", "booted", "-j"]), { encoding: "utf-8" });
|
|
11
12
|
const data = JSON.parse(output) as {
|
|
12
13
|
devices: Record<string, Array<{ udid: string; name: string; state: string }>>;
|
|
13
14
|
};
|
|
@@ -24,6 +25,17 @@ export function findBootedDevice(): string | null {
|
|
|
24
25
|
return null;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Whether the selected device set lists `udid`, in any case, as simctl reads it. */
|
|
29
|
+
export function isInDeviceSet(udid: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
const output = execFileSync("xcrun", simctlArguments(["list", "devices", "-j"]), { encoding: "utf-8" });
|
|
32
|
+
const data = JSON.parse(output) as { devices: Record<string, Array<{ udid: string }>> };
|
|
33
|
+
return Object.values(data.devices).some((devices) => devices.some((device) => device.udid.toUpperCase() === udid.toUpperCase()));
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/**
|
|
28
40
|
* Resolve a device name or UDID to a UDID. A UDID is returned as-is; a name is
|
|
29
41
|
* matched case-insensitively against `simctl list devices`. Exits the process
|
|
@@ -34,7 +46,7 @@ export function resolveDevice(nameOrUDID: string): string {
|
|
|
34
46
|
return nameOrUDID;
|
|
35
47
|
}
|
|
36
48
|
try {
|
|
37
|
-
const output =
|
|
49
|
+
const output = execFileSync("xcrun", simctlArguments(["list", "devices", "-j"]), { encoding: "utf-8" });
|
|
38
50
|
const data = JSON.parse(output) as {
|
|
39
51
|
devices: Record<string, Array<{ udid: string; name: string; state: string }>>;
|
|
40
52
|
};
|
package/src/doctor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { duoModelPath, resolveDuoModel, resolveDuoRendererExecutable } from "./duo-renderer";
|
|
13
|
+
import { simctlArguments } from "./simctl";
|
|
13
14
|
import { readDeviceHubInputShadowed } from "./device-hub-input";
|
|
14
15
|
import { redactHome } from "./diagnostics-redact";
|
|
15
16
|
|
|
@@ -144,7 +145,7 @@ export async function collectHostDiagnostics(options: HostDiagnosticsOptions = {
|
|
|
144
145
|
|
|
145
146
|
/** Simulator facts for one UDID from `simctl list -j`, plus the guest Device Hub input flag. */
|
|
146
147
|
export async function collectDeviceDiagnostics(udid: string, probe: Probe = defaultProbe): Promise<Record<string, unknown>> {
|
|
147
|
-
const listing = await probe("/usr/bin/xcrun", ["
|
|
148
|
+
const listing = await probe("/usr/bin/xcrun", simctlArguments(["list", "devices", "-j"]));
|
|
148
149
|
let device: Record<string, unknown> | null = null;
|
|
149
150
|
try {
|
|
150
151
|
const parsed = JSON.parse(listing ?? "{}") as { devices?: Record<string, Array<Record<string, unknown>>> };
|
package/src/duo-state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { SimulatorOrientation } from "./client/types";
|
|
3
|
+
import { simctlArguments } from "./simctl";
|
|
3
4
|
|
|
4
5
|
export function parseHingeSample(line: string): number | null {
|
|
5
6
|
if (!/AngleValid:Y\b/.test(line)) return null;
|
|
@@ -60,7 +61,7 @@ export class DuoStateMonitor {
|
|
|
60
61
|
refreshOrientation(): void {
|
|
61
62
|
if (this.stopped || this.enumeration) return;
|
|
62
63
|
if (this.poll) clearTimeout(this.poll);
|
|
63
|
-
this.enumeration = execFile("/usr/bin/xcrun", ["
|
|
64
|
+
this.enumeration = execFile("/usr/bin/xcrun", simctlArguments(["io", this.udid, "enumerate"]),
|
|
64
65
|
{ timeout: 3000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
65
66
|
this.enumeration = undefined;
|
|
66
67
|
if (this.stopped) return;
|
|
@@ -88,7 +89,7 @@ export class DuoStateMonitor {
|
|
|
88
89
|
this.onState(this.state);
|
|
89
90
|
this.refreshOrientation();
|
|
90
91
|
};
|
|
91
|
-
const child = spawn("/usr/bin/xcrun", ["
|
|
92
|
+
const child = spawn("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "stream", "--style", "compact", "--predicate", predicate]), { stdio: ["ignore", "pipe", "ignore"] });
|
|
92
93
|
this.panelMonitor = child;
|
|
93
94
|
let pending = "";
|
|
94
95
|
child.stdout.setEncoding("utf8");
|
|
@@ -102,7 +103,7 @@ export class DuoStateMonitor {
|
|
|
102
103
|
}
|
|
103
104
|
});
|
|
104
105
|
// Seed from this boot's most recent transition, then let live events win.
|
|
105
|
-
this.panelSeed = execFile("/usr/bin/xcrun", ["
|
|
106
|
+
this.panelSeed = execFile("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "show", "--last", "boot", "--style", "compact", "--predicate", predicate]),
|
|
106
107
|
{ timeout: 10000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
107
108
|
this.panelSeed = undefined;
|
|
108
109
|
if (!error && !liveSample) {
|
package/src/event-log.ts
CHANGED
|
@@ -627,9 +627,11 @@ function isUploadPlumbing(tokens: string[]): boolean {
|
|
|
627
627
|
function simctlCommand(tokens: string[]): { verb: string; args: string[] } | null {
|
|
628
628
|
const i = tokens.findIndex((token) => token === "simctl");
|
|
629
629
|
if (i < 0 || tokens[i - 1] !== "xcrun") return null;
|
|
630
|
-
|
|
630
|
+
// `simctl --set <path> <verb>` (a `--device-set`) names its verb after the set.
|
|
631
|
+
const v = tokens[i + 1] === "--set" ? i + 3 : i + 1;
|
|
632
|
+
const verb = tokens[v];
|
|
631
633
|
if (!verb) return null;
|
|
632
|
-
return { verb, args: tokens.slice(
|
|
634
|
+
return { verb, args: tokens.slice(v + 1) };
|
|
633
635
|
}
|
|
634
636
|
|
|
635
637
|
function serveSimCommand(tokens: string[]): { verb: string; args: string[] } | null {
|
package/src/helper-lifecycle.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { readFileSync, unlinkSync } from "fs";
|
|
|
16
16
|
import { sleepSync } from "./runtime";
|
|
17
17
|
import { getPortHolders } from "./ports";
|
|
18
18
|
import { debugHelper, debugState } from "./debug";
|
|
19
|
+
import { sameDeviceSet } from "./simctl";
|
|
19
20
|
import type { ServeSimDeviceState } from "./state";
|
|
20
21
|
import { writeServeSimState } from "./state";
|
|
21
22
|
|
|
@@ -205,10 +206,14 @@ export type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
|
|
|
205
206
|
* `recycle-*` as a reason to drop the pidfile — the process is still alive.
|
|
206
207
|
*/
|
|
207
208
|
export function classifyStaleState(
|
|
208
|
-
state: { pid: number; device: string },
|
|
209
|
+
state: { pid: number; device: string; deviceSet?: string },
|
|
209
210
|
booted: Set<string> | null,
|
|
210
211
|
selfPid: number,
|
|
212
|
+
deviceSet: string | undefined,
|
|
211
213
|
): StaleStateAction {
|
|
214
|
+
// `booted` lists only this process's device set; another set's simulator
|
|
215
|
+
// is not in it whether or not it is booted.
|
|
216
|
+
if (!sameDeviceSet(state.deviceSet, deviceSet)) return "keep";
|
|
212
217
|
if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
|
|
213
218
|
return state.pid === selfPid ? "recycle-self" : "recycle-helper";
|
|
214
219
|
}
|
package/src/host-rpc.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { homedir } from "os";
|
|
|
14
14
|
import { basename, dirname, join } from "path";
|
|
15
15
|
import { PERMISSION_SERVICES } from "./client/utils/permissions";
|
|
16
16
|
import { shellJoin } from "./shell-quote";
|
|
17
|
+
import { simctlArguments } from "./simctl";
|
|
17
18
|
import { getUiOption, setUiOption } from "./ui-settings";
|
|
18
19
|
|
|
19
20
|
// Narrow host actions for the preview page, carried as `{id, rpc}` frames on
|
|
@@ -193,7 +194,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
193
194
|
};
|
|
194
195
|
|
|
195
196
|
const appContainer = async (udid: string, bundleId: string): Promise<string> => {
|
|
196
|
-
const r = await run("xcrun", ["
|
|
197
|
+
const r = await run("xcrun", simctlArguments(["get_app_container", udid, bundleId, "app"]));
|
|
197
198
|
if (r.exitCode !== 0) fail(r.stderr.trim() || "App not found on simulator");
|
|
198
199
|
const appPath = r.stdout.trim();
|
|
199
200
|
if (!appPath) fail("Empty app path");
|
|
@@ -216,7 +217,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
216
217
|
const watch = p.watch === true;
|
|
217
218
|
await requireDevice(udid);
|
|
218
219
|
if (watch) return { ...(await run("osascript", WATCH_HOME_SCRIPT.flatMap((line) => ["-e", line]))) };
|
|
219
|
-
return { ...(await run("xcrun", ["
|
|
220
|
+
return { ...(await run("xcrun", simctlArguments(["launch", udid, "com.apple.springboard"]))) };
|
|
220
221
|
},
|
|
221
222
|
|
|
222
223
|
async screenshot(p) {
|
|
@@ -224,11 +225,11 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
224
225
|
const display = p.display === undefined ? undefined : enumParam(p, "display", DISPLAYS);
|
|
225
226
|
await requireDevice(udid);
|
|
226
227
|
const path = join(desktop(), `serve-sim-screenshot-${timestampSlug()}.png`);
|
|
227
|
-
const r = await run("xcrun", [
|
|
228
|
-
"
|
|
228
|
+
const r = await run("xcrun", simctlArguments([
|
|
229
|
+
"io", udid, "screenshot",
|
|
229
230
|
...(display ? [`--display=${display}`] : []),
|
|
230
231
|
path,
|
|
231
|
-
]);
|
|
232
|
+
]));
|
|
232
233
|
return { ...r, path: r.exitCode === 0 ? path : "" };
|
|
233
234
|
},
|
|
234
235
|
|
|
@@ -421,7 +422,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
421
422
|
path = screenshotPathParam(p);
|
|
422
423
|
}
|
|
423
424
|
await requireDevice(udid);
|
|
424
|
-
return { ...(await run("xcrun", ["
|
|
425
|
+
return { ...(await run("xcrun", simctlArguments(["addmedia", udid, path]))) };
|
|
425
426
|
},
|
|
426
427
|
|
|
427
428
|
async installApp(p) {
|
|
@@ -430,7 +431,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
430
431
|
const path = uploadPathParam(p, "path", ["install"]);
|
|
431
432
|
if (!path.endsWith(".ipa")) fail("invalid path");
|
|
432
433
|
await requireDevice(udid);
|
|
433
|
-
return { ...(await run("xcrun", ["
|
|
434
|
+
return { ...(await run("xcrun", simctlArguments(["install", udid, path]))) };
|
|
434
435
|
},
|
|
435
436
|
|
|
436
437
|
// Set (lat/lng) or clear the simulated location.
|
|
@@ -439,7 +440,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
|
|
|
439
440
|
const clear = p.lat === undefined && p.lng === undefined;
|
|
440
441
|
const point = clear ? null : `${coordinate(p, "lat", 90).toFixed(7)},${coordinate(p, "lng", 180).toFixed(7)}`;
|
|
441
442
|
await requireDevice(udid);
|
|
442
|
-
return { ...(await run("xcrun", ["
|
|
443
|
+
return { ...(await run("xcrun", simctlArguments(["location", udid, ...(point ? ["set", point] : ["clear"])]))) };
|
|
443
444
|
},
|
|
444
445
|
};
|
|
445
446
|
|
package/src/middleware.ts
CHANGED
|
@@ -51,7 +51,7 @@ import { createExecUpgradeHandler, EXEC_DISABLED_MESSAGE, type UiRequestHandler
|
|
|
51
51
|
import { createHostRpcHandler } from "./host-rpc";
|
|
52
52
|
import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
|
|
53
53
|
import type { PreviewInitialState } from "./preview-initial-state";
|
|
54
|
-
import { simctlBootStatusArguments } from "./simctl";
|
|
54
|
+
import { resolveDeviceSet, sameDeviceSet, simctlArguments, simctlBootStatusArguments } from "./simctl";
|
|
55
55
|
import { authBootstrapPage } from "./auth-bootstrap";
|
|
56
56
|
import {
|
|
57
57
|
AUTH_COOKIE,
|
|
@@ -222,7 +222,7 @@ export function parseForegroundAppLogMessage(message: string): { bundleId: strin
|
|
|
222
222
|
function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
|
|
223
223
|
if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true);
|
|
224
224
|
return new Promise((resolve) => {
|
|
225
|
-
execFile("xcrun", ["
|
|
225
|
+
execFile("xcrun", simctlArguments(["get_app_container", udid, bundleId, "app"]),
|
|
226
226
|
{ timeout: 2000 },
|
|
227
227
|
(err, stdout) => {
|
|
228
228
|
if (err) return resolve(false);
|
|
@@ -280,7 +280,7 @@ async function getBootedUdids(): Promise<Set<string> | null> {
|
|
|
280
280
|
const stdout = await new Promise<string>((resolve, reject) => {
|
|
281
281
|
execFile(
|
|
282
282
|
"xcrun",
|
|
283
|
-
["
|
|
283
|
+
simctlArguments(["list", "devices", "booted", "-j"]),
|
|
284
284
|
{ encoding: "utf-8", timeout: 3_000 },
|
|
285
285
|
(err, stdout) => {
|
|
286
286
|
if (err) {
|
|
@@ -338,6 +338,7 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
|
|
|
338
338
|
return [];
|
|
339
339
|
}
|
|
340
340
|
const booted = await getBootedUdids();
|
|
341
|
+
const deviceSet = resolveDeviceSet();
|
|
341
342
|
const states: ServeSimState[] = [];
|
|
342
343
|
for (const f of files) {
|
|
343
344
|
const path = join(STATE_DIR, f);
|
|
@@ -350,11 +351,14 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
|
|
|
350
351
|
continue;
|
|
351
352
|
}
|
|
352
353
|
if (state.pid !== recorded.pid) persistRecoveredState(state);
|
|
354
|
+
// Previews in every device set share this folder; another set's
|
|
355
|
+
// simulator is not one this preview can stream.
|
|
356
|
+
if (!sameDeviceSet(state.deviceSet, deviceSet)) continue;
|
|
353
357
|
// Helper alive but its simulator was shut down — the MJPEG stream
|
|
354
358
|
// would accept connections yet never produce frames, leaving the
|
|
355
359
|
// preview stuck on "Connecting...". Recycle the stale state so the
|
|
356
360
|
// caller can spawn a fresh helper bound to whatever is booted.
|
|
357
|
-
const action = classifyStaleState(state, booted, process.pid);
|
|
361
|
+
const action = classifyStaleState(state, booted, process.pid, deviceSet);
|
|
358
362
|
if (action !== "keep") {
|
|
359
363
|
if (action === "recycle-self") {
|
|
360
364
|
// This device is streamed in-process by *us* (the close button just
|
|
@@ -439,11 +443,12 @@ function endpoint(base: string, path: string, device: string): string {
|
|
|
439
443
|
/**
|
|
440
444
|
* A state record as it may be served over HTTP: drops the server-private
|
|
441
445
|
* fields (the auth-token fingerprint and the exec opt-in recorded for
|
|
442
|
-
* `--detach` reuse checks), which no
|
|
446
|
+
* `--detach` reuse checks, and the host path of the device set), which no
|
|
447
|
+
* client needs.
|
|
443
448
|
*/
|
|
444
449
|
export function publicState(state: ServeSimState): ServeSimState {
|
|
445
|
-
if (!("authTokenSha256" in state) && !("allowExec" in state)) return state;
|
|
446
|
-
const { authTokenSha256: _fingerprint, allowExec: _allowExec, ...rest } = state;
|
|
450
|
+
if (!("authTokenSha256" in state) && !("allowExec" in state) && !("deviceSet" in state)) return state;
|
|
451
|
+
const { authTokenSha256: _fingerprint, allowExec: _allowExec, deviceSet: _deviceSet, ...rest } = state;
|
|
447
452
|
return rest;
|
|
448
453
|
}
|
|
449
454
|
|
|
@@ -887,7 +892,7 @@ export async function startDeviceInProcess(
|
|
|
887
892
|
record: Pick<ServeSimDeviceState, "authTokenSha256" | "allowExec"> = {},
|
|
888
893
|
): Promise<string | null> {
|
|
889
894
|
// `simctl boot` errors when already booted — ignore and let bootstatus confirm.
|
|
890
|
-
await new Promise<void>((resolve) => execFile("xcrun", ["
|
|
895
|
+
await new Promise<void>((resolve) => execFile("xcrun", simctlArguments(["boot", udid]), () => resolve()));
|
|
891
896
|
const ready = await new Promise<boolean>((resolve) => {
|
|
892
897
|
// Boot was already requested above. Passing `-b` redundantly can remain
|
|
893
898
|
// blocked on Xcode 27 even after `simctl list` reports Booted, so only
|
|
@@ -898,7 +903,7 @@ export async function startDeviceInProcess(
|
|
|
898
903
|
// bootstatus can exit non-zero even when the device is actually ready;
|
|
899
904
|
// confirm against the real state before reporting failure.
|
|
900
905
|
const booted = await new Promise<boolean>((resolve) => {
|
|
901
|
-
execFile("xcrun", ["
|
|
906
|
+
execFile("xcrun", simctlArguments(["list", "devices", "-j"]), (err, stdout) => {
|
|
902
907
|
if (err) return resolve(false);
|
|
903
908
|
try {
|
|
904
909
|
const data = JSON.parse(stdout) as { devices: Record<string, Array<{ udid: string; state: string }>> };
|
|
@@ -1244,7 +1249,7 @@ function listAllSimulators(): Promise<SimctlDevice[]> {
|
|
|
1244
1249
|
return new Promise((resolve) => {
|
|
1245
1250
|
execFile(
|
|
1246
1251
|
"xcrun",
|
|
1247
|
-
["
|
|
1252
|
+
simctlArguments(["list", "devices", "-j"]),
|
|
1248
1253
|
{ encoding: "utf-8", timeout: 3_000 },
|
|
1249
1254
|
(err, stdout) => {
|
|
1250
1255
|
if (err) return resolve([]);
|
|
@@ -1502,6 +1507,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1502
1507
|
const authToken = resolveAuthToken(options?.authToken);
|
|
1503
1508
|
const serveDiagnostics = createDiagnosticsHandler(options?.diagnostics);
|
|
1504
1509
|
const serveSimBin = options?.serveSimBin ?? resolveServeSimBin();
|
|
1510
|
+
// A dev server that embeds this middleware never runs the CLI: settle the
|
|
1511
|
+
// device set (and refuse a bad SERVE_SIM_DEVICE_SET) here, not mid-request.
|
|
1512
|
+
resolveDeviceSet();
|
|
1505
1513
|
// Every path this middleware may answer. Standalone serve-sim mounts at "/"
|
|
1506
1514
|
// (base ""), so it owns the whole origin; embedded mounts own only `base/…`.
|
|
1507
1515
|
const ownsPath = (pathname: string) =>
|
|
@@ -1904,7 +1912,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1904
1912
|
// Drop the snapshot so the next /grid/api call re-queries simctl
|
|
1905
1913
|
// and prunes any helper bound to this now-shutdown device.
|
|
1906
1914
|
bootedSnapshot = { at: 0, booted: null };
|
|
1907
|
-
execFile("xcrun", ["
|
|
1915
|
+
execFile("xcrun", simctlArguments(["shutdown", udid]), { timeout: 30_000 }, (err, _stdout, stderr) => {
|
|
1908
1916
|
if (err) {
|
|
1909
1917
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1910
1918
|
res.end(JSON.stringify({
|
|
@@ -2450,13 +2458,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2450
2458
|
// AX bridge may be warming up — the log tail fills in once anything moves.
|
|
2451
2459
|
}
|
|
2452
2460
|
|
|
2453
|
-
const child: ChildProcess = spawn("xcrun", [
|
|
2454
|
-
"
|
|
2461
|
+
const child: ChildProcess = spawn("xcrun", simctlArguments([
|
|
2462
|
+
"spawn", udid, "log", "stream",
|
|
2455
2463
|
"--style", "ndjson",
|
|
2456
2464
|
"--level", "info",
|
|
2457
2465
|
"--predicate",
|
|
2458
2466
|
'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
|
|
2459
|
-
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
2467
|
+
]), { stdio: ["ignore", "pipe", "ignore"] });
|
|
2460
2468
|
|
|
2461
2469
|
let closed = false;
|
|
2462
2470
|
const emitApp = async (bundleId: string, pid?: number) => {
|
package/src/native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync } from "fs";
|
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
14
14
|
import { KeySequencer, type KeyType } from "./key-sequencer";
|
|
15
15
|
import { maxBitrateFromEnv } from "./max-bitrate";
|
|
16
|
+
import { resolveDeviceSet } from "./simctl";
|
|
16
17
|
|
|
17
18
|
export type { KeyType } from "./key-sequencer";
|
|
18
19
|
|
|
@@ -59,6 +60,7 @@ interface NativeAddon {
|
|
|
59
60
|
SimCapture: new (udid: string, maxBitrate: number | null) => SimCaptureHandle;
|
|
60
61
|
axDescribe(udid: string): Promise<string>;
|
|
61
62
|
axFrontmost(udid: string): Promise<string>;
|
|
63
|
+
setDeviceSet(path: string | null): void;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// (data, width, height, flags) — codec 0=MJPEG 1=AVCC 2=size-only; flags bit0=desc bit1=keyframe.
|
|
@@ -164,7 +166,12 @@ function resolveAddon(): string {
|
|
|
164
166
|
|
|
165
167
|
let addon: NativeAddon | undefined;
|
|
166
168
|
function load(): NativeAddon {
|
|
167
|
-
if (!addon)
|
|
169
|
+
if (!addon) {
|
|
170
|
+
addon = require(resolveAddon()) as NativeAddon;
|
|
171
|
+
// The addon cannot read the set from the environment: under Bun a
|
|
172
|
+
// `process.env` write never reaches it.
|
|
173
|
+
addon.setDeviceSet(resolveDeviceSet() ?? null);
|
|
174
|
+
}
|
|
168
175
|
return addon;
|
|
169
176
|
}
|
|
170
177
|
|
package/src/simctl.ts
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
// The CoreSimulator device set to use instead of Xcode's default, as
|
|
6
|
+
// `simctl --set` takes it. Tools that keep simulators in a set of their own
|
|
7
|
+
// (device pools, CI runners, Xcode Previews) put them where `simctl list` and
|
|
8
|
+
// Simulator.app never look. The variable is how the set reaches what the CLI
|
|
9
|
+
// starts (a --detach child, host-action subprocesses) and a dev server that
|
|
10
|
+
// embeds the middleware without the CLI.
|
|
11
|
+
const DEVICE_SET_ENV = "SERVE_SIM_DEVICE_SET";
|
|
12
|
+
const DEFAULT_DEVICE_SET = join(homedir(), "Library/Developer/CoreSimulator/Devices");
|
|
13
|
+
|
|
14
|
+
let resolved: { path: string | undefined } | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The device set this process targets: an absolute directory, or undefined
|
|
18
|
+
* for Xcode's default set. The first call settles it from `flag` (the CLI's
|
|
19
|
+
* `--device-set`), or else from SERVE_SIM_DEVICE_SET, and writes the result
|
|
20
|
+
* back to that variable for the processes serve-sim starts. Every later call
|
|
21
|
+
* returns the same value, so simctl, the native addon and the state file
|
|
22
|
+
* never disagree. Throws when the value is not an existing directory, or
|
|
23
|
+
* when a flag arrives after the set was settled.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveDeviceSet(flag?: string): string | undefined {
|
|
26
|
+
if (resolved !== undefined) {
|
|
27
|
+
if (flag !== undefined) throw new Error("--device-set arrived after the device set was settled");
|
|
28
|
+
return resolved.path;
|
|
29
|
+
}
|
|
30
|
+
const fromEnv = process.env[DEVICE_SET_ENV]?.trim();
|
|
31
|
+
let path: string | undefined;
|
|
32
|
+
if (flag !== undefined) path = existingDirectory(flag, "--device-set");
|
|
33
|
+
else if (fromEnv) path = existingDirectory(fromEnv, DEVICE_SET_ENV);
|
|
34
|
+
resolved = { path };
|
|
35
|
+
if (path === undefined) delete process.env[DEVICE_SET_ENV];
|
|
36
|
+
else process.env[DEVICE_SET_ENV] = path;
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function existingDirectory(value: string, source: string): string {
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
const expanded = trimmed === "~" || trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(1)) : trimmed;
|
|
43
|
+
const path = resolve(expanded);
|
|
44
|
+
let isDirectory = false;
|
|
45
|
+
try {
|
|
46
|
+
isDirectory = statSync(path).isDirectory();
|
|
47
|
+
} catch {}
|
|
48
|
+
if (trimmed === "" || !isDirectory) {
|
|
49
|
+
throw new Error(`${source}: invalid device set '${value}': expected an existing directory (a simctl --set path).`);
|
|
50
|
+
}
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether two device-set paths (undefined: Xcode's default set) name one
|
|
56
|
+
* set, symlinks resolved, so `/tmp/set` and `/private/tmp/set` match, and
|
|
57
|
+
* so does Xcode's default folder named explicitly.
|
|
58
|
+
*/
|
|
59
|
+
export function sameDeviceSet(a: string | undefined, b: string | undefined): boolean {
|
|
60
|
+
if (a === b) return true;
|
|
61
|
+
const real = (path: string) => {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(path);
|
|
64
|
+
} catch {
|
|
65
|
+
return resolve(path);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return real(a ?? DEFAULT_DEVICE_SET) === real(b ?? DEFAULT_DEVICE_SET);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `xcrun` arguments that run `simctl <args>` against the device set. */
|
|
72
|
+
export function simctlArguments(args: readonly string[]): string[] {
|
|
73
|
+
const set = resolveDeviceSet();
|
|
74
|
+
return set === undefined ? ["simctl", ...args] : ["simctl", "--set", set, ...args];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The directory holding one folder per simulator UDID in the device set. */
|
|
78
|
+
export function simulatorDevicesDir(): string {
|
|
79
|
+
return resolveDeviceSet() ?? DEFAULT_DEVICE_SET;
|
|
80
|
+
}
|
|
81
|
+
|
|
1
82
|
/**
|
|
2
83
|
* Arguments for monitoring a boot that serve-sim has already requested.
|
|
3
84
|
*
|
|
@@ -6,6 +87,6 @@
|
|
|
6
87
|
* even though the device is already Booted. This form only monitors the boot
|
|
7
88
|
* transition that serve-sim requested.
|
|
8
89
|
*/
|
|
9
|
-
export function simctlBootStatusArguments(udid: string): [
|
|
10
|
-
return ["
|
|
90
|
+
export function simctlBootStatusArguments(udid: string): string[] {
|
|
91
|
+
return simctlArguments(["bootstatus", udid]);
|
|
11
92
|
}
|
package/src/state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tmpdir } from "os";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import { resolveDeviceSet } from "./simctl";
|
|
3
4
|
import {
|
|
4
5
|
chmodSync,
|
|
5
6
|
closeSync,
|
|
@@ -46,6 +47,12 @@ export interface ServeSimDeviceState {
|
|
|
46
47
|
* opt-in differs from the one requested. Absent means off.
|
|
47
48
|
*/
|
|
48
49
|
allowExec?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The `--device-set` the server's simulator lives in. Absent means Xcode's
|
|
52
|
+
* default set. Previews in different sets share this state folder, and
|
|
53
|
+
* each can tell only whether its own set's simulators are booted.
|
|
54
|
+
*/
|
|
55
|
+
deviceSet?: string;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
@@ -65,7 +72,9 @@ export function inProcessServeSimState(
|
|
|
65
72
|
// leading slash (e.g. "foo") still yields well-formed `…:port/foo/helper/…`.
|
|
66
73
|
const trimmed = base.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
67
74
|
const prefix = trimmed === "" ? "" : `/${trimmed}`;
|
|
75
|
+
const deviceSet = resolveDeviceSet();
|
|
68
76
|
return {
|
|
77
|
+
...(deviceSet !== undefined ? { deviceSet } : {}),
|
|
69
78
|
pid: process.pid,
|
|
70
79
|
port,
|
|
71
80
|
device: udid,
|
package/src/ui-settings.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "fs";
|
|
|
3
3
|
import { join, resolve } from "path";
|
|
4
4
|
import { findBootedDevice, resolveDevice } from "./device";
|
|
5
5
|
import { dirnameOf } from "./runtime";
|
|
6
|
+
import { simctlArguments } from "./simctl";
|
|
6
7
|
|
|
7
8
|
// Bun's bundler inlines a bare `__dirname` as the build machine's source
|
|
8
9
|
// directory; shadow it with the runtime location so the published bundle
|
|
@@ -199,14 +200,14 @@ function run(file: string, args: string[]): Promise<string> {
|
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
function simctlUi(udid: string, subcommand: string, value?: string): Promise<string> {
|
|
202
|
-
const args = ["
|
|
203
|
+
const args = simctlArguments(["ui", udid, subcommand]);
|
|
203
204
|
if (value !== undefined) args.push(value);
|
|
204
205
|
return run("xcrun", args);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
async function axRun(udid: string, ...args: string[]): Promise<string> {
|
|
208
209
|
const tool = await axSettingsTool();
|
|
209
|
-
return run("xcrun", ["
|
|
210
|
+
return run("xcrun", simctlArguments(["spawn", udid, tool, ...args]));
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
function toToggle(simctlValue: string): string {
|