@houwert/conductor 0.24.1 → 0.26.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.
@@ -5,6 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.detectPlatform = detectPlatform;
7
7
  exports.getDriverPort = getDriverPort;
8
+ exports.getInputPort = getInputPort;
9
+ exports.getStreamPort = getStreamPort;
10
+ exports.getInprocDylibPath = getInprocDylibPath;
11
+ exports.getHidBinaryPath = getHidBinaryPath;
12
+ exports.getCaptureBinaryPath = getCaptureBinaryPath;
8
13
  exports.installDriver = installDriver;
9
14
  exports.isSimulatorBooted = isSimulatorBooted;
10
15
  exports.isPortOpen = isPortOpen;
@@ -89,6 +94,8 @@ const TVOS_BASE_PORT = 2075;
89
94
  const ANDROID_BASE_PORT = 3763;
90
95
  const WEB_BASE_PORT = 4075;
91
96
  const VEGA_BASE_PORT = 5075;
97
+ const INPUT_BASE_PORT = 7075;
98
+ const STREAM_BASE_PORT = 8075;
92
99
  const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
93
100
  const PORT_LOCK = PORT_FILE + '.lock';
94
101
  const PORT_LOCK_TIMEOUT_MS = 5000;
@@ -175,6 +182,48 @@ async function getDriverPort(platform, deviceId) {
175
182
  return port;
176
183
  });
177
184
  }
185
+ /**
186
+ * Assign and persist a streaming-input WebSocket port for a device. Kept in a
187
+ * separate namespace from the driver port (a device has both): the driver port
188
+ * serves the XCUITest/gRPC HTTP surface, this one the persistent input socket.
189
+ */
190
+ async function getInputPort(deviceId) {
191
+ return withPortLock(() => {
192
+ const state = readPortState();
193
+ if (!state.inputAssignments)
194
+ state.inputAssignments = {};
195
+ if (state.nextInputPort === undefined)
196
+ state.nextInputPort = INPUT_BASE_PORT;
197
+ if (state.inputAssignments[deviceId] !== undefined) {
198
+ return state.inputAssignments[deviceId];
199
+ }
200
+ const port = state.nextInputPort++;
201
+ state.inputAssignments[deviceId] = port;
202
+ writePortState(state);
203
+ return port;
204
+ });
205
+ }
206
+ /**
207
+ * Assign and persist a streaming-video WebSocket port for a device. Its own
208
+ * namespace: a device has a driver port (control), an input port (pointer/key
209
+ * frames), and this stream port (H.264 video fan-out).
210
+ */
211
+ async function getStreamPort(deviceId) {
212
+ return withPortLock(() => {
213
+ const state = readPortState();
214
+ if (!state.streamAssignments)
215
+ state.streamAssignments = {};
216
+ if (state.nextStreamPort === undefined)
217
+ state.nextStreamPort = STREAM_BASE_PORT;
218
+ if (state.streamAssignments[deviceId] !== undefined) {
219
+ return state.streamAssignments[deviceId];
220
+ }
221
+ const port = state.nextStreamPort++;
222
+ state.streamAssignments[deviceId] = port;
223
+ writePortState(state);
224
+ return port;
225
+ });
226
+ }
178
227
  // ── Driver paths (bundled dev fallback + runtime download cache) ──────────────
179
228
  /**
180
229
  * Walk up from __dirname to find the package root (the directory containing
@@ -225,6 +274,44 @@ async function getDriversDir() {
225
274
  });
226
275
  return _driversDirPromise;
227
276
  }
277
+ /**
278
+ * Absolute path to the injectable in-process control library
279
+ * (`<platform>-inproc/Conductor.framework/Conductor`), built by
280
+ * `packages/ios-inproc/tools/build-inproc-dylib.sh`. Passed to the target app
281
+ * via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES at launch. iOS and tvOS ship separate
282
+ * builds (different simulator SDK).
283
+ */
284
+ async function getInprocDylibPath(platform = 'ios') {
285
+ const dir = await getDriversDir();
286
+ return path_1.default.join(dir, `${platform}-inproc`, 'Conductor.framework', 'Conductor');
287
+ }
288
+ /**
289
+ * Absolute path to the host-side CoreSimulator HID injector (`ios-hid/conductor-hid`),
290
+ * built by `packages/ios-hid/tools/build-hid.sh`. Optional: only used for live
291
+ * held-touch drags when CONDUCTOR_IOS_HID=1 and the binary is present. Returns
292
+ * null if it hasn't been built.
293
+ */
294
+ async function getHidBinaryPath() {
295
+ const dir = await getDriversDir().catch(() => null);
296
+ if (!dir)
297
+ return null;
298
+ const p = path_1.default.join(dir, 'ios-hid', 'conductor-hid');
299
+ return fs_1.default.existsSync(p) ? p : null;
300
+ }
301
+ /**
302
+ * Absolute path to the host-side Simulator video capture binary
303
+ * (`ios-capture/conductor-capture`), built by
304
+ * `packages/ios-capture/tools/build-capture.sh`. Captures the framebuffer via
305
+ * SimulatorKit and serves a VideoToolbox H.264 Annex B stream. Returns null if
306
+ * it hasn't been built (streaming falls back to unavailable).
307
+ */
308
+ async function getCaptureBinaryPath() {
309
+ const dir = await getDriversDir().catch(() => null);
310
+ if (!dir)
311
+ return null;
312
+ const p = path_1.default.join(dir, 'ios-capture', 'conductor-capture');
313
+ return fs_1.default.existsSync(p) ? p : null;
314
+ }
228
315
  async function ensureDriversCache(pkgRoot) {
229
316
  const pkgJsonPath = path_1.default.join(pkgRoot, 'package.json');
230
317
  const pkg = JSON.parse(fs_1.default.readFileSync(pkgJsonPath, 'utf-8'));
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.compileEval = compileEval;
7
+ /**
8
+ * Compiles user Swift into a fresh dylib exporting `conductor_eval`, then drops
9
+ * it into the target app's container so the injected library can dlopen it.
10
+ *
11
+ * `expr` mode wraps an expression; `full` mode takes the whole function body.
12
+ * Each build is a uniquely-named dylib to avoid dlopen caching.
13
+ */
14
+ const child_process_1 = require("child_process");
15
+ const util_1 = require("util");
16
+ const crypto_1 = __importDefault(require("crypto"));
17
+ const promises_1 = __importDefault(require("fs/promises"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const run = (0, util_1.promisify)(child_process_1.execFile);
21
+ const EXPR_TEMPLATE = (code) => `import Foundation
22
+ import UIKit
23
+ import SwiftUI
24
+
25
+ @_cdecl("conductor_eval")
26
+ public func conductor_eval() -> UnsafePointer<CChar> {
27
+ let __result: Any = {
28
+ ${code}
29
+ }()
30
+ return UnsafePointer(strdup(String(describing: __result))!)
31
+ }
32
+ `;
33
+ const FULL_TEMPLATE = (code) => `import Foundation
34
+ import UIKit
35
+ import SwiftUI
36
+
37
+ @_cdecl("conductor_eval")
38
+ public func conductor_eval() -> UnsafePointer<CChar> {
39
+ ${code}
40
+ }
41
+ `;
42
+ async function detectTarget(platform) {
43
+ const sdkName = platform === 'tvos' ? 'appletvsimulator' : 'iphonesimulator';
44
+ const sdkPlatform = platform === 'tvos' ? 'tvos' : 'ios';
45
+ const [{ stdout: sdkPath }, { stdout: sdkVer }] = await Promise.all([
46
+ run('xcrun', ['--sdk', sdkName, '--show-sdk-path']),
47
+ run('xcrun', ['--sdk', sdkName, '--show-sdk-version']),
48
+ ]);
49
+ const major = sdkVer.trim().split('.')[0];
50
+ const arch = os_1.default.arch() === 'arm64' ? 'arm64' : 'x86_64';
51
+ return {
52
+ sdkName,
53
+ sdkPath: sdkPath.trim(),
54
+ target: `${arch}-apple-${sdkPlatform}${major}.0-simulator`,
55
+ };
56
+ }
57
+ /**
58
+ * Compile `code` and place the dylib inside the app container's tmp so the
59
+ * sandboxed app can dlopen it. Returns the container-relative host path (which,
60
+ * on the simulator, is the same path the app opens).
61
+ */
62
+ async function compileEval(code, mode, platform, deviceId, bundleId) {
63
+ const start = Date.now();
64
+ const { sdkName, sdkPath, target } = await detectTarget(platform);
65
+ const source = (mode === 'full' ? FULL_TEMPLATE : EXPR_TEMPLATE)(code);
66
+ const hash = crypto_1.default.createHash('md5').update(source).digest('hex').slice(0, 10);
67
+ const workDir = path_1.default.join(os_1.default.tmpdir(), 'conductor-eval');
68
+ await promises_1.default.mkdir(workDir, { recursive: true });
69
+ const swiftFile = path_1.default.join(workDir, `eval_${hash}.swift`);
70
+ const dylibName = `eval_${hash}_${Date.now()}.dylib`;
71
+ const dylibPath = path_1.default.join(workDir, dylibName);
72
+ await promises_1.default.writeFile(swiftFile, source, 'utf-8');
73
+ try {
74
+ await run('xcrun', [
75
+ '-sdk',
76
+ sdkName,
77
+ 'swiftc',
78
+ '-target',
79
+ target,
80
+ '-sdk',
81
+ sdkPath,
82
+ '-emit-library',
83
+ '-Onone',
84
+ '-enable-testing',
85
+ '-o',
86
+ dylibPath,
87
+ // Unresolved symbols (app/system) resolve at dlopen time in the host process.
88
+ '-Xlinker',
89
+ '-undefined',
90
+ '-Xlinker',
91
+ 'dynamic_lookup',
92
+ swiftFile,
93
+ ]);
94
+ }
95
+ catch (err) {
96
+ return {
97
+ ok: false,
98
+ error: `compile failed:\n${err.stderr ?? String(err)}`,
99
+ };
100
+ }
101
+ // Ad-hoc sign (sim rejects unsigned dylibs for dlopen on newer runtimes).
102
+ await run('codesign', ['--force', '--sign', '-', dylibPath]).catch(() => { });
103
+ // Copy into the app's data container tmp so the sandboxed app can read it.
104
+ try {
105
+ const { stdout } = await run('xcrun', [
106
+ 'simctl',
107
+ 'get_app_container',
108
+ deviceId,
109
+ bundleId,
110
+ 'data',
111
+ ]);
112
+ const container = stdout.trim();
113
+ // System apps (and any without a data container) print "(null)"; use the raw
114
+ // path there — simulator apps are host processes and can dlopen it directly.
115
+ if (!container.startsWith('/'))
116
+ throw new Error('no data container');
117
+ const containerTmp = path_1.default.join(container, 'tmp');
118
+ await promises_1.default.mkdir(containerTmp, { recursive: true });
119
+ const dest = path_1.default.join(containerTmp, dylibName);
120
+ await promises_1.default.copyFile(dylibPath, dest);
121
+ return { ok: true, dylibPath: dest, info: `compiled in ${Date.now() - start}ms` };
122
+ }
123
+ catch {
124
+ // Fall back to the raw temp path — simulator apps can usually open it directly.
125
+ return {
126
+ ok: true,
127
+ dylibPath,
128
+ info: `compiled in ${Date.now() - start}ms (container copy skipped)`,
129
+ };
130
+ }
131
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.IOSHidClient = void 0;
7
+ /**
8
+ * Node client for the host-side CoreSimulator HID injector
9
+ * (`packages/ios-hid`, built to `drivers/ios-hid/conductor-hid`).
10
+ *
11
+ * This is the streaming pointer backend: unlike XCUITest's atomic
12
+ * `_XCT_synthesizeEvent`, it holds a touch DOWN and streams moves, so a live
13
+ * drag animates on-device as the finger moves. Opt-in (CONDUCTOR_IOS_HID=1) and
14
+ * single-touch — multitouch/discrete gestures stay on the XCUITest path.
15
+ *
16
+ * Talks newline-delimited JSON over the binary's stdio, one request in flight
17
+ * at a time (FIFO correlation, mirroring the XCTest driver's simplicity).
18
+ */
19
+ const child_process_1 = require("child_process");
20
+ const readline_1 = __importDefault(require("readline"));
21
+ const PHASE_TO_TYPE = {
22
+ down: 0,
23
+ move: 1,
24
+ up: 2,
25
+ cancel: 2, // release the held touch
26
+ };
27
+ class IOSHidClient {
28
+ constructor(binaryPath, udid) {
29
+ this.binaryPath = binaryPath;
30
+ this.udid = udid;
31
+ this.proc = null;
32
+ this.rl = null;
33
+ this.pending = [];
34
+ }
35
+ start() {
36
+ if (this.proc)
37
+ return;
38
+ this.proc = (0, child_process_1.spawn)(this.binaryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
39
+ this.rl = readline_1.default.createInterface({ input: this.proc.stdout });
40
+ this.rl.on('line', (line) => {
41
+ const resolve = this.pending.shift();
42
+ if (!resolve)
43
+ return;
44
+ try {
45
+ resolve(JSON.parse(line));
46
+ }
47
+ catch {
48
+ resolve({ ok: false, error: `bad response: ${line}` });
49
+ }
50
+ });
51
+ this.proc.on('exit', () => {
52
+ this.proc = null;
53
+ this.rl = null;
54
+ // Fail any in-flight requests so callers don't hang.
55
+ while (this.pending.length)
56
+ this.pending.shift()({ ok: false, error: 'hid process exited' });
57
+ });
58
+ }
59
+ stop() {
60
+ this.proc?.kill();
61
+ this.proc = null;
62
+ this.rl = null;
63
+ }
64
+ send(req) {
65
+ if (!this.proc)
66
+ this.start();
67
+ return new Promise((resolve) => {
68
+ this.pending.push(resolve);
69
+ this.proc.stdin.write(JSON.stringify(req) + '\n');
70
+ });
71
+ }
72
+ async ping() {
73
+ const r = await this.send({ cmd: 'ping' });
74
+ return r.ok;
75
+ }
76
+ /** Inject a touch phase at normalized coords. */
77
+ async touch(nx, ny, phase) {
78
+ const r = await this.send({
79
+ cmd: 'touch',
80
+ udid: this.udid,
81
+ x: nx,
82
+ y: ny,
83
+ type: PHASE_TO_TYPE[phase],
84
+ });
85
+ if (!r.ok)
86
+ throw new Error(`hid touch failed (rc=${r.rc ?? '?'}) ${r.error ?? ''}`.trim());
87
+ }
88
+ /** Adapter for the router's live-pointer path (single finger; id ignored). */
89
+ asLivePointer() {
90
+ return {
91
+ pointer: (_id, phase, nx, ny) => this.touch(nx, ny, phase),
92
+ };
93
+ }
94
+ }
95
+ exports.IOSHidClient = IOSHidClient;
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.InprocClient = void 0;
7
+ exports.getInprocPort = getInprocPort;
8
+ /**
9
+ * Client + port allocation for the injected in-process control library
10
+ * (`packages/ios-inproc`). This is a second inspection plane that runs *inside*
11
+ * the target app — distinct from the external XCUITest driver in `ios.ts`.
12
+ *
13
+ * The dylib is injected at launch (see IOSDriver.launchApp `inject` option). The
14
+ * CLI allocates a loopback port per device, hands it to the app via
15
+ * SIMCTL_CHILD_CONDUCTOR_INPROC_PORT, and connects here. Simulator apps share
16
+ * the host loopback, so 127.0.0.1:<port> reaches the in-process server — the same
17
+ * mechanism the XCUITest driver uses on :1075.
18
+ */
19
+ const http_1 = __importDefault(require("http"));
20
+ const fs_1 = __importDefault(require("fs"));
21
+ const os_1 = __importDefault(require("os"));
22
+ const path_1 = __importDefault(require("path"));
23
+ const INPROC_BASE_PORT = 6075;
24
+ const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'inproc-ports.json');
25
+ function readState() {
26
+ try {
27
+ return JSON.parse(fs_1.default.readFileSync(PORT_FILE, 'utf-8'));
28
+ }
29
+ catch {
30
+ return { assignments: {}, nextPort: INPROC_BASE_PORT };
31
+ }
32
+ }
33
+ /**
34
+ * Deterministic in-process control port for a device. Stable across calls so
35
+ * the launcher and any later `native-*` command agree without a discovery file.
36
+ */
37
+ function getInprocPort(deviceId) {
38
+ const state = readState();
39
+ const existing = state.assignments[deviceId];
40
+ if (existing !== undefined)
41
+ return existing;
42
+ const port = state.nextPort;
43
+ state.assignments[deviceId] = port;
44
+ state.nextPort = port + 1;
45
+ fs_1.default.mkdirSync(path_1.default.dirname(PORT_FILE), { recursive: true });
46
+ fs_1.default.writeFileSync(PORT_FILE, JSON.stringify(state, null, 2));
47
+ return port;
48
+ }
49
+ /** HTTP/JSON client for the in-process control server. */
50
+ class InprocClient {
51
+ constructor(port, host = '127.0.0.1') {
52
+ this.port = port;
53
+ this.host = host;
54
+ }
55
+ get(reqPath, timeoutMs = 5000) {
56
+ return new Promise((resolve, reject) => {
57
+ const req = http_1.default.request({ hostname: this.host, port: this.port, path: reqPath, method: 'GET' }, (res) => {
58
+ const chunks = [];
59
+ res.on('data', (c) => chunks.push(c));
60
+ res.on('end', () => {
61
+ try {
62
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
63
+ }
64
+ catch (err) {
65
+ reject(err);
66
+ }
67
+ });
68
+ res.on('error', reject);
69
+ });
70
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc request timed out')));
71
+ req.on('error', reject);
72
+ req.end();
73
+ });
74
+ }
75
+ ping(timeoutMs = 5000) {
76
+ return this.get('/ping', timeoutMs);
77
+ }
78
+ /** Full native view hierarchy with colors, fonts, text, and layer visuals. */
79
+ inspect(timeoutMs = 15000) {
80
+ return this.get('/inspect', timeoutMs);
81
+ }
82
+ /** Navigation / view-controller hierarchy (stacks, tabs, presented, titles). */
83
+ nav(timeoutMs = 15000) {
84
+ return this.get('/nav', timeoutMs);
85
+ }
86
+ /** Full property detail for one view (by id from inspect). */
87
+ view(id, timeoutMs = 8000) {
88
+ return this.get(`/view?id=${encodeURIComponent(id)}`, timeoutMs);
89
+ }
90
+ /** Live-set a whitelisted property on a view (alpha, backgroundColor, text, frame, …). */
91
+ set(id, key, value, timeoutMs = 8000) {
92
+ const q = `id=${encodeURIComponent(id)}&key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`;
93
+ return this.get(`/set?${q}`, timeoutMs);
94
+ }
95
+ /** React Native Fabric props: typed ViewProps + the raw JS prop bag. */
96
+ props(id, timeoutMs = 8000) {
97
+ return this.get(`/props?id=${encodeURIComponent(id)}`, timeoutMs);
98
+ }
99
+ /** Auto Layout constraints affecting a view + ambiguity flag. */
100
+ constraints(id, timeoutMs = 8000) {
101
+ return this.get(`/constraints?id=${encodeURIComponent(id)}`, timeoutMs);
102
+ }
103
+ /** Topmost view at a window point, plus its ancestor chain. */
104
+ hittest(x, y, timeoutMs = 8000) {
105
+ return this.get(`/hittest?x=${x}&y=${y}`, timeoutMs);
106
+ }
107
+ /** Flash a highlight overlay over a view on the device. */
108
+ highlight(id, timeoutMs = 8000) {
109
+ return this.get(`/highlight?id=${encodeURIComponent(id)}`, timeoutMs);
110
+ }
111
+ /** Search views by class-name substring and/or text substring. */
112
+ find(q, timeoutMs = 10000) {
113
+ const parts = [];
114
+ if (q.className)
115
+ parts.push(`class=${encodeURIComponent(q.className)}`);
116
+ if (q.text)
117
+ parts.push(`text=${encodeURIComponent(q.text)}`);
118
+ return this.get(`/find?${parts.join('&')}`, timeoutMs);
119
+ }
120
+ /** PNG of the whole key window. */
121
+ screenshot(timeoutMs = 20000) {
122
+ return this.getBuffer('/screenshot', timeoutMs);
123
+ }
124
+ /**
125
+ * PNG of a single view in isolation — the texture for a 3D exploded-layer
126
+ * viewer. Default (`includeSubviews=false`) captures only this view's own
127
+ * content, so each node is a distinct transparent layer plane.
128
+ */
129
+ snapshot(id, includeSubviews = false, timeoutMs = 15000) {
130
+ const q = `id=${encodeURIComponent(id)}&subviews=${includeSubviews ? 'true' : 'false'}`;
131
+ return this.getBuffer(`/snapshot?${q}`, timeoutMs);
132
+ }
133
+ /**
134
+ * PNG crop of a window-absolute rect (use a node's `absFrame` from inspect).
135
+ * Composites whatever is drawn there — works for UIImageView, RN Fabric, etc.
136
+ */
137
+ image(frame, timeoutMs = 15000) {
138
+ return this.getBuffer(`/image?frame=${frame.x},${frame.y},${frame.w},${frame.h}`, timeoutMs);
139
+ }
140
+ /** Raw GET to any endpoint, parsed as JSON. */
141
+ rawJson(reqPath, timeoutMs = 15000) {
142
+ const path = reqPath.startsWith('/') ? reqPath : `/${reqPath}`;
143
+ return this.get(path, timeoutMs);
144
+ }
145
+ /** Raw GET to any endpoint — returns the content-type and bytes (json or image). */
146
+ rawRequest(reqPath, timeoutMs = 20000) {
147
+ const path = reqPath.startsWith('/') ? reqPath : `/${reqPath}`;
148
+ return new Promise((resolve, reject) => {
149
+ const req = http_1.default.request({ hostname: this.host, port: this.port, path, method: 'GET' }, (res) => {
150
+ const chunks = [];
151
+ res.on('data', (c) => chunks.push(c));
152
+ res.on('end', () => resolve({ contentType: res.headers['content-type'] ?? '', body: Buffer.concat(chunks) }));
153
+ res.on('error', reject);
154
+ });
155
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc request timed out')));
156
+ req.on('error', reject);
157
+ req.end();
158
+ });
159
+ }
160
+ getBuffer(reqPath, timeoutMs) {
161
+ return new Promise((resolve, reject) => {
162
+ const req = http_1.default.request({ hostname: this.host, port: this.port, path: reqPath, method: 'GET' }, (res) => {
163
+ const chunks = [];
164
+ res.on('data', (c) => chunks.push(c));
165
+ res.on('end', () => {
166
+ const buf = Buffer.concat(chunks);
167
+ const type = res.headers['content-type'] ?? '';
168
+ if (!type.startsWith('image/')) {
169
+ reject(new Error(`expected an image, got: ${buf.toString('utf-8').slice(0, 200)}`));
170
+ return;
171
+ }
172
+ resolve(buf);
173
+ });
174
+ res.on('error', reject);
175
+ });
176
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc image request timed out')));
177
+ req.on('error', reject);
178
+ req.end();
179
+ });
180
+ }
181
+ /** True once the in-process server answers a ping (poll after an injected launch). */
182
+ async waitUntilReady(timeoutMs = 10000) {
183
+ const deadline = Date.now() + timeoutMs;
184
+ while (Date.now() < deadline) {
185
+ try {
186
+ const res = await this.ping(1500);
187
+ if (res.status === 'ok')
188
+ return true;
189
+ }
190
+ catch {
191
+ /* not up yet */
192
+ }
193
+ await new Promise((r) => setTimeout(r, 300));
194
+ }
195
+ return false;
196
+ }
197
+ }
198
+ exports.InprocClient = InprocClient;
@@ -89,14 +89,35 @@ class IOSDriver {
89
89
  invalidateHierarchyCache() {
90
90
  this.hierarchyCache = null;
91
91
  }
92
- simctl(args) {
92
+ simctl(args, childEnv) {
93
93
  const _id = this.requireDeviceId();
94
94
  return new Promise((resolve, reject) => {
95
- const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], { stdio: 'ignore' });
95
+ const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], {
96
+ stdio: 'ignore',
97
+ env: childEnv ? this.launchEnv(childEnv) : undefined,
98
+ });
96
99
  proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`xcrun simctl ${args[0]} failed (exit ${code})`)));
97
100
  proc.on('error', reject);
98
101
  });
99
102
  }
103
+ /**
104
+ * Build the env for a `simctl launch` that must forward vars into the target
105
+ * app. `simctl` copies `SIMCTL_CHILD_*` vars into the app's real environment at
106
+ * exec() — the only path dyld honours for restricted vars like
107
+ * DYLD_INSERT_LIBRARIES. Inherited SIMCTL_CHILD_* are stripped first so stale
108
+ * values (from a parent shell) can't override ours and silently break injection.
109
+ */
110
+ launchEnv(childEnv) {
111
+ const env = { ...process.env };
112
+ for (const key of Object.keys(env)) {
113
+ if (key.startsWith('SIMCTL_CHILD_'))
114
+ delete env[key];
115
+ }
116
+ for (const [key, value] of Object.entries(childEnv)) {
117
+ env[`SIMCTL_CHILD_${key}`] = value;
118
+ }
119
+ return env;
120
+ }
100
121
  simctlCapture(args) {
101
122
  this.requireDeviceId();
102
123
  return new Promise((resolve, reject) => {
@@ -167,15 +188,25 @@ class IOSDriver {
167
188
  });
168
189
  this.invalidateHierarchyCache();
169
190
  }
170
- async launchApp(bundleId, args) {
171
- if (args && Object.keys(args).length > 0) {
191
+ async launchApp(bundleId, args, inject) {
192
+ const argPairs = [];
193
+ for (const [key, value] of Object.entries(args ?? {})) {
194
+ argPairs.push(`-${key}`, value);
195
+ }
196
+ if (inject) {
197
+ // Injection requires simctl launch with SIMCTL_CHILD_ env — the XCTest
198
+ // /launchApp path only activates and can't set environment.
199
+ const deviceId = this.requireDeviceId();
200
+ await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
201
+ await this.simctl(['launch', '--terminate-running-process', deviceId, bundleId, ...argPairs], {
202
+ DYLD_INSERT_LIBRARIES: inject.dylibPath,
203
+ CONDUCTOR_INPROC_PORT: String(inject.inprocPort),
204
+ });
205
+ }
206
+ else if (argPairs.length > 0) {
172
207
  const deviceId = this.requireDeviceId();
173
208
  // xctest /launchApp doesn't support launch args — use simctl
174
209
  await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
175
- const argPairs = [];
176
- for (const [key, value] of Object.entries(args)) {
177
- argPairs.push(`-${key}`, value);
178
- }
179
210
  await this.simctl(['launch', '--terminate-running-process', deviceId, bundleId, ...argPairs]);
180
211
  }
181
212
  else {