@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.
- package/dist/commands/input-server.js +28 -0
- package/dist/commands/launch-app.js +30 -3
- package/dist/commands/native-rn.js +110 -0
- package/dist/commands/native.js +320 -0
- package/dist/commands/stream-server.js +28 -0
- package/dist/daemon/client.js +5 -0
- package/dist/daemon/h264-annexb.js +264 -0
- package/dist/daemon/input-backends.js +203 -0
- package/dist/daemon/input-protocol.js +40 -0
- package/dist/daemon/input-router.js +110 -0
- package/dist/daemon/input-server.js +124 -0
- package/dist/daemon/server.js +131 -0
- package/dist/daemon/video-hub.js +58 -0
- package/dist/daemon/video-protocol.js +38 -0
- package/dist/daemon/video-server.js +88 -0
- package/dist/daemon/video-source.js +142 -0
- package/dist/drivers/bootstrap.js +87 -0
- package/dist/drivers/eval-compiler.js +131 -0
- package/dist/drivers/ios-hid.js +95 -0
- package/dist/drivers/ios-inproc.js +198 -0
- package/dist/drivers/ios.js +39 -8
- package/dist/drivers/metro-scripts.js +174 -0
- package/dist/index.js +146 -0
- package/dist/runner.js +78 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +28 -0
- package/skills/conductor-device-setup/SKILL.md +1 -1
- package/skills/conductor-inspect/SKILL.md +43 -0
- package/skills/conductor-metro-debugger/SKILL.md +10 -0
|
@@ -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;
|
package/dist/drivers/ios.js
CHANGED
|
@@ -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], {
|
|
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
|
-
|
|
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 {
|