@houwert/conductor 0.24.0 → 0.25.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/start-device.js +62 -4
- package/dist/daemon/client.js +5 -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 +84 -0
- package/dist/daemon/web-server.js +80 -79
- package/dist/drivers/bootstrap.js +49 -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 +142 -0
- package/dist/runner.js +33 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +13 -0
- package/skills/conductor-device-setup/SKILL.md +2 -1
- package/skills/conductor-inspect/SKILL.md +43 -0
- package/skills/conductor-metro-debugger/SKILL.md +10 -0
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.parseAriaSnapshot = parseAriaSnapshot;
|
|
7
|
+
exports.mergeCanvasNodes = mergeCanvasNodes;
|
|
7
8
|
exports.startWebServer = startWebServer;
|
|
8
9
|
exports.stopWebServer = stopWebServer;
|
|
9
10
|
exports.getCdpPort = getCdpPort;
|
|
@@ -225,11 +226,22 @@ async function resolveBoundingBoxes(page, elements) {
|
|
|
225
226
|
const el = queue.shift();
|
|
226
227
|
if (el.ref) {
|
|
227
228
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
// One round-trip per ref for bounds + the element's own `data-testid`. Reading identity
|
|
230
|
+
// from the element itself (not by geometric overlap) is what lets canvas focus later be
|
|
231
|
+
// joined to the right node by identity — see mergeCanvasNodes.
|
|
232
|
+
const info = (await page
|
|
233
|
+
.locator(`aria-ref=${el.ref}`)
|
|
234
|
+
.first()
|
|
235
|
+
.evaluate(`(node) => {
|
|
236
|
+
const r = node.getBoundingClientRect();
|
|
237
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height,
|
|
238
|
+
testId: node.getAttribute('data-testid') || '' };
|
|
239
|
+
}`, undefined, { timeout: 750 }));
|
|
240
|
+
if (info.width > 0 && info.height > 0) {
|
|
241
|
+
el.bounds = { x: info.x, y: info.y, width: info.width, height: info.height };
|
|
232
242
|
}
|
|
243
|
+
if (info.testId)
|
|
244
|
+
el.testId = info.testId;
|
|
233
245
|
}
|
|
234
246
|
catch {
|
|
235
247
|
// Element not visible or locator failed — skip
|
|
@@ -469,44 +481,56 @@ async function stampFocusFromDocumentActiveElement(page, elements) {
|
|
|
469
481
|
rectPick.best.focused = true;
|
|
470
482
|
}
|
|
471
483
|
}
|
|
472
|
-
/**
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
const centerInside = mcx >= bounds.x &&
|
|
486
|
-
mcx <= bounds.x + bounds.width &&
|
|
487
|
-
mcy >= bounds.y &&
|
|
488
|
-
mcy <= bounds.y + bounds.height;
|
|
489
|
-
return centerInside ? iou + 1 : iou;
|
|
490
|
-
}
|
|
491
|
-
/** Find the existing tree node whose bounds best overlap a mirror rect (>0.5 score). */
|
|
492
|
-
function bestOverlappingNode(elements, m) {
|
|
493
|
-
let best = null;
|
|
494
|
-
let bestScore = 0.5;
|
|
495
|
-
const walk = (els) => {
|
|
484
|
+
/**
|
|
485
|
+
* Merge canvas scene-graph nodes into the ARIA tree by identity (`data-testid`), never by
|
|
486
|
+
* geometry. A mirror node that shares a testId with an existing (natively-read) tree node
|
|
487
|
+
* enriches it and, authoritatively, carries its `data-focused` state onto it. Mirror nodes with
|
|
488
|
+
* no counterpart (canvas-only / off-screen scene nodes) are appended as their own nodes.
|
|
489
|
+
*
|
|
490
|
+
* Because focus rides identity — not bounds — an open drawer overlapping a focused tile can no
|
|
491
|
+
* longer steal the tile's focus, and a tile's identity can't smear onto a neighbour it happens
|
|
492
|
+
* to overlap.
|
|
493
|
+
*/
|
|
494
|
+
function mergeCanvasNodes(elements, mirror) {
|
|
495
|
+
const byTestId = new Map();
|
|
496
|
+
const index = (els) => {
|
|
496
497
|
for (const el of els) {
|
|
497
|
-
if (el.
|
|
498
|
-
|
|
499
|
-
if (s > bestScore) {
|
|
500
|
-
bestScore = s;
|
|
501
|
-
best = el;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
498
|
+
if (el.testId)
|
|
499
|
+
byTestId.set(el.testId, el);
|
|
504
500
|
if (el.children)
|
|
505
|
-
|
|
501
|
+
index(el.children);
|
|
506
502
|
}
|
|
507
503
|
};
|
|
508
|
-
|
|
509
|
-
|
|
504
|
+
index(elements);
|
|
505
|
+
for (const m of mirror) {
|
|
506
|
+
const bounds = m.width > 0 && m.height > 0
|
|
507
|
+
? { x: m.x, y: m.y, width: m.width, height: m.height }
|
|
508
|
+
: undefined;
|
|
509
|
+
const existing = m.testId ? byTestId.get(m.testId) : undefined;
|
|
510
|
+
if (existing) {
|
|
511
|
+
if (m.focused)
|
|
512
|
+
existing.focused = true;
|
|
513
|
+
if (!existing.name && m.name)
|
|
514
|
+
existing.name = m.name;
|
|
515
|
+
if (bounds && !existing.bounds)
|
|
516
|
+
existing.bounds = bounds;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
const node = {
|
|
520
|
+
role: m.role,
|
|
521
|
+
name: m.name,
|
|
522
|
+
ref: '',
|
|
523
|
+
testId: m.testId || undefined,
|
|
524
|
+
bounds,
|
|
525
|
+
enabled: !m.disabled,
|
|
526
|
+
focused: m.focused,
|
|
527
|
+
children: [],
|
|
528
|
+
};
|
|
529
|
+
elements.push(node);
|
|
530
|
+
if (m.testId)
|
|
531
|
+
byTestId.set(m.testId, node);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
510
534
|
}
|
|
511
535
|
/**
|
|
512
536
|
* Harvest a canvas DOM-inspector mirror into the hierarchy. Canvas webtv frameworks
|
|
@@ -514,10 +538,11 @@ function bestOverlappingNode(elements, m) {
|
|
|
514
538
|
* off-screen `<div>`s carrying `data-testid` (real identity) and `data-focused="true"` (focus
|
|
515
539
|
* — the canvas owns `document.activeElement`, so the normal focus path can't see it).
|
|
516
540
|
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
541
|
+
* Nodes are joined to the ARIA tree by identity (see mergeCanvasNodes); canvas-only nodes are
|
|
542
|
+
* appended. Mirror rects come from `getBoundingClientRect`, i.e. the same viewport-CSS-pixel
|
|
543
|
+
* space taps use — drive TV apps at the app's native resolution (e.g. `set-viewport 1920 1080`)
|
|
544
|
+
* so lower scene nodes aren't off-screen. Returns true when a mirror was present (a canvas app),
|
|
545
|
+
* so the caller can skip the DOM `activeElement` focus fallback.
|
|
521
546
|
*/
|
|
522
547
|
async function harvestDomMirror(page, elements) {
|
|
523
548
|
let mirror = null;
|
|
@@ -541,40 +566,12 @@ async function harvestDomMirror(page, elements) {
|
|
|
541
566
|
})()`));
|
|
542
567
|
}
|
|
543
568
|
catch {
|
|
544
|
-
return;
|
|
569
|
+
return false;
|
|
545
570
|
}
|
|
546
571
|
if (!mirror || mirror.length === 0)
|
|
547
|
-
return;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (mirror.some((m) => m.focused))
|
|
551
|
-
clearFocusedFlags(elements);
|
|
552
|
-
for (const m of mirror) {
|
|
553
|
-
const bounds = m.width > 0 && m.height > 0
|
|
554
|
-
? { x: m.x, y: m.y, width: m.width, height: m.height }
|
|
555
|
-
: undefined;
|
|
556
|
-
const target = bounds ? bestOverlappingNode(elements, m) : null;
|
|
557
|
-
if (target) {
|
|
558
|
-
if (m.testId)
|
|
559
|
-
target.testId = m.testId;
|
|
560
|
-
if (m.focused)
|
|
561
|
-
target.focused = true;
|
|
562
|
-
if (!target.name && m.name)
|
|
563
|
-
target.name = m.name;
|
|
564
|
-
}
|
|
565
|
-
else {
|
|
566
|
-
elements.push({
|
|
567
|
-
role: m.role,
|
|
568
|
-
name: m.name,
|
|
569
|
-
ref: '',
|
|
570
|
-
testId: m.testId || undefined,
|
|
571
|
-
bounds,
|
|
572
|
-
enabled: !m.disabled,
|
|
573
|
-
focused: m.focused,
|
|
574
|
-
children: [],
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
}
|
|
572
|
+
return false;
|
|
573
|
+
mergeCanvasNodes(elements, mirror);
|
|
574
|
+
return true;
|
|
578
575
|
}
|
|
579
576
|
// ── Web server ───────────────────────────────────────────────────────────────
|
|
580
577
|
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
@@ -983,12 +980,16 @@ async function handleRequest(req, res, dlog) {
|
|
|
983
980
|
await resolveBoundingBoxes(p, elements);
|
|
984
981
|
await resolveBoundingBoxesBatch(p, elements);
|
|
985
982
|
await resolveBoundingBoxesByRole(p, elements);
|
|
983
|
+
// Focus source depends on the app, and the two are mutually exclusive:
|
|
984
|
+
// • Canvas/inspector apps (Lightning/WPE) own document.activeElement on the <canvas>,
|
|
985
|
+
// so focus lives on scene nodes as `data-focused` — merged by identity.
|
|
986
|
+
// • Plain DOM apps expose focus via ARIA `[active]` / document.activeElement.
|
|
986
987
|
clearFocusedFlags(elements);
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
988
|
+
const isCanvasApp = await harvestDomMirror(p, elements);
|
|
989
|
+
if (!isCanvasApp) {
|
|
990
|
+
applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
|
|
991
|
+
await stampFocusFromDocumentActiveElement(p, elements);
|
|
992
|
+
}
|
|
992
993
|
jsonResponse(res, {
|
|
993
994
|
url: p.url(),
|
|
994
995
|
title: await p.title(),
|
|
@@ -5,6 +5,9 @@ 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.getInprocDylibPath = getInprocDylibPath;
|
|
10
|
+
exports.getHidBinaryPath = getHidBinaryPath;
|
|
8
11
|
exports.installDriver = installDriver;
|
|
9
12
|
exports.isSimulatorBooted = isSimulatorBooted;
|
|
10
13
|
exports.isPortOpen = isPortOpen;
|
|
@@ -89,6 +92,7 @@ const TVOS_BASE_PORT = 2075;
|
|
|
89
92
|
const ANDROID_BASE_PORT = 3763;
|
|
90
93
|
const WEB_BASE_PORT = 4075;
|
|
91
94
|
const VEGA_BASE_PORT = 5075;
|
|
95
|
+
const INPUT_BASE_PORT = 7075;
|
|
92
96
|
const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
|
|
93
97
|
const PORT_LOCK = PORT_FILE + '.lock';
|
|
94
98
|
const PORT_LOCK_TIMEOUT_MS = 5000;
|
|
@@ -175,6 +179,27 @@ async function getDriverPort(platform, deviceId) {
|
|
|
175
179
|
return port;
|
|
176
180
|
});
|
|
177
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Assign and persist a streaming-input WebSocket port for a device. Kept in a
|
|
184
|
+
* separate namespace from the driver port (a device has both): the driver port
|
|
185
|
+
* serves the XCUITest/gRPC HTTP surface, this one the persistent input socket.
|
|
186
|
+
*/
|
|
187
|
+
async function getInputPort(deviceId) {
|
|
188
|
+
return withPortLock(() => {
|
|
189
|
+
const state = readPortState();
|
|
190
|
+
if (!state.inputAssignments)
|
|
191
|
+
state.inputAssignments = {};
|
|
192
|
+
if (state.nextInputPort === undefined)
|
|
193
|
+
state.nextInputPort = INPUT_BASE_PORT;
|
|
194
|
+
if (state.inputAssignments[deviceId] !== undefined) {
|
|
195
|
+
return state.inputAssignments[deviceId];
|
|
196
|
+
}
|
|
197
|
+
const port = state.nextInputPort++;
|
|
198
|
+
state.inputAssignments[deviceId] = port;
|
|
199
|
+
writePortState(state);
|
|
200
|
+
return port;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
178
203
|
// ── Driver paths (bundled dev fallback + runtime download cache) ──────────────
|
|
179
204
|
/**
|
|
180
205
|
* Walk up from __dirname to find the package root (the directory containing
|
|
@@ -225,6 +250,30 @@ async function getDriversDir() {
|
|
|
225
250
|
});
|
|
226
251
|
return _driversDirPromise;
|
|
227
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Absolute path to the injectable in-process control library
|
|
255
|
+
* (`<platform>-inproc/Conductor.framework/Conductor`), built by
|
|
256
|
+
* `packages/ios-inproc/tools/build-inproc-dylib.sh`. Passed to the target app
|
|
257
|
+
* via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES at launch. iOS and tvOS ship separate
|
|
258
|
+
* builds (different simulator SDK).
|
|
259
|
+
*/
|
|
260
|
+
async function getInprocDylibPath(platform = 'ios') {
|
|
261
|
+
const dir = await getDriversDir();
|
|
262
|
+
return path_1.default.join(dir, `${platform}-inproc`, 'Conductor.framework', 'Conductor');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Absolute path to the host-side CoreSimulator HID injector (`ios-hid/conductor-hid`),
|
|
266
|
+
* built by `packages/ios-hid/tools/build-hid.sh`. Optional: only used for live
|
|
267
|
+
* held-touch drags when CONDUCTOR_IOS_HID=1 and the binary is present. Returns
|
|
268
|
+
* null if it hasn't been built.
|
|
269
|
+
*/
|
|
270
|
+
async function getHidBinaryPath() {
|
|
271
|
+
const dir = await getDriversDir().catch(() => null);
|
|
272
|
+
if (!dir)
|
|
273
|
+
return null;
|
|
274
|
+
const p = path_1.default.join(dir, 'ios-hid', 'conductor-hid');
|
|
275
|
+
return fs_1.default.existsSync(p) ? p : null;
|
|
276
|
+
}
|
|
228
277
|
async function ensureDriversCache(pkgRoot) {
|
|
229
278
|
const pkgJsonPath = path_1.default.join(pkgRoot, 'package.json');
|
|
230
279
|
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;
|