@houwert/conductor 0.24.1 → 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.
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.iosBackend = iosBackend;
4
+ exports.androidBackend = androidBackend;
5
+ const clampNorm = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
6
+ // ── iOS / tvOS (XCUITest driver) ──────────────────────────────────────────────
7
+ // key-name → XCUITest pressKey value
8
+ const IOS_KEY = {
9
+ Backspace: 'delete',
10
+ Delete: 'delete',
11
+ Enter: 'enter',
12
+ Return: 'enter',
13
+ Tab: 'tab',
14
+ Space: 'space',
15
+ ' ': 'space',
16
+ };
17
+ // button/key-name → XCUITest pressButton value
18
+ const IOS_BUTTON = {
19
+ home: 'home',
20
+ Home: 'home',
21
+ lock: 'lock',
22
+ Lock: 'lock',
23
+ power: 'lock',
24
+ Power: 'lock',
25
+ // tvOS remote (also reachable via the tvremote frame)
26
+ up: 'up',
27
+ down: 'down',
28
+ left: 'left',
29
+ right: 'right',
30
+ select: 'select',
31
+ menu: 'menu',
32
+ playPause: 'playPause',
33
+ ArrowUp: 'up',
34
+ ArrowDown: 'down',
35
+ ArrowLeft: 'left',
36
+ ArrowRight: 'right',
37
+ };
38
+ function iosBackend(driver) {
39
+ let size = null;
40
+ const dims = async () => {
41
+ if (!size) {
42
+ const info = await driver.deviceInfo();
43
+ size = { w: info.widthPoints, h: info.heightPoints };
44
+ }
45
+ return size;
46
+ };
47
+ const tvos = driver.platform === 'tvos';
48
+ return {
49
+ platform: tvos ? 'tvos' : 'ios',
50
+ capabilities(liveDrag) {
51
+ return {
52
+ touch: true,
53
+ drag: true,
54
+ multitouch: true,
55
+ buttons: ['home', 'lock'],
56
+ keyboard: true,
57
+ text: true,
58
+ tvRemote: tvos,
59
+ springboard: true,
60
+ liveDrag,
61
+ binaryPointer: false,
62
+ coord: 'normalized',
63
+ };
64
+ },
65
+ async tap(nx, ny, durationMs) {
66
+ const { w, h } = await dims();
67
+ await driver.tap(clampNorm(nx) * w, clampNorm(ny) * h, durationMs ? durationMs / 1000 : undefined);
68
+ },
69
+ async gesture(paths) {
70
+ const { w, h } = await dims();
71
+ const converted = paths.map((p) => ({
72
+ steps: p.steps.map((s) => ({
73
+ x: clampNorm(s.nx) * w,
74
+ y: clampNorm(s.ny) * h,
75
+ dt: s.tMs / 1000,
76
+ })),
77
+ }));
78
+ await driver.gesturePath(converted);
79
+ },
80
+ async swipe(nsx, nsy, nex, ney, durationMs) {
81
+ const { w, h } = await dims();
82
+ await driver.swipe(clampNorm(nsx) * w, clampNorm(nsy) * h, clampNorm(nex) * w, clampNorm(ney) * h, durationMs / 1000);
83
+ },
84
+ async text(value) {
85
+ await driver.inputText(value);
86
+ },
87
+ async key(code, opts) {
88
+ // XCUITest pressKey is atomic — act on key-down, ignore the paired up.
89
+ if (opts && opts.down === false)
90
+ return;
91
+ const k = IOS_KEY[code];
92
+ if (k) {
93
+ await driver.pressKey(k);
94
+ return;
95
+ }
96
+ const b = IOS_BUTTON[code];
97
+ if (b)
98
+ await driver.pressButton(b);
99
+ },
100
+ async button(name, holdMs) {
101
+ const b = IOS_BUTTON[name];
102
+ if (b)
103
+ await driver.pressButton(b, holdMs ? holdMs / 1000 : undefined);
104
+ },
105
+ };
106
+ }
107
+ // ── Android (gRPC APK + adb) ───────────────────────────────────────────────────
108
+ const ANDROID_KEYCODE = {
109
+ Home: 3,
110
+ home: 3,
111
+ Back: 4,
112
+ back: 4,
113
+ Enter: 66,
114
+ Return: 66,
115
+ Backspace: 67,
116
+ Delete: 67,
117
+ Tab: 61,
118
+ Space: 62,
119
+ ' ': 62,
120
+ Escape: 111,
121
+ Lock: 26,
122
+ Power: 26,
123
+ power: 26,
124
+ VolumeUp: 24,
125
+ volumeUp: 24,
126
+ VolumeDown: 25,
127
+ volumeDown: 25,
128
+ Menu: 82,
129
+ menu: 82,
130
+ Search: 84,
131
+ ArrowUp: 19,
132
+ ArrowDown: 20,
133
+ ArrowLeft: 21,
134
+ ArrowRight: 22,
135
+ up: 19,
136
+ down: 20,
137
+ left: 21,
138
+ right: 22,
139
+ select: 23,
140
+ playPause: 85,
141
+ };
142
+ function androidBackend(driver) {
143
+ let size = null;
144
+ const dims = async () => {
145
+ if (!size) {
146
+ const info = await driver.deviceInfo();
147
+ size = { w: info.widthPixels, h: info.heightPixels };
148
+ }
149
+ return size;
150
+ };
151
+ return {
152
+ platform: 'android',
153
+ capabilities(liveDrag) {
154
+ return {
155
+ touch: true,
156
+ drag: true,
157
+ multitouch: true,
158
+ buttons: ['home', 'back', 'menu', 'power', 'volumeUp', 'volumeDown'],
159
+ keyboard: true,
160
+ text: true,
161
+ tvRemote: true, // Android-TV dpad via keyevents
162
+ springboard: false,
163
+ liveDrag,
164
+ binaryPointer: false,
165
+ coord: 'normalized',
166
+ };
167
+ },
168
+ async tap(nx, ny) {
169
+ const { w, h } = await dims();
170
+ await driver.tap(clampNorm(nx) * w, clampNorm(ny) * h);
171
+ },
172
+ async gesture(paths) {
173
+ const { w, h } = await dims();
174
+ const converted = paths.map((p) => ({
175
+ steps: p.steps.map((s) => ({
176
+ x: clampNorm(s.nx) * w,
177
+ y: clampNorm(s.ny) * h,
178
+ dt_ms: Math.round(s.tMs),
179
+ })),
180
+ }));
181
+ await driver.gesturePath(converted);
182
+ },
183
+ async swipe(nsx, nsy, nex, ney, durationMs) {
184
+ const { w, h } = await dims();
185
+ await driver.swipe(clampNorm(nsx) * w, clampNorm(nsy) * h, clampNorm(nex) * w, clampNorm(ney) * h, durationMs);
186
+ },
187
+ async text(value) {
188
+ await driver.inputText(value);
189
+ },
190
+ async key(code, opts) {
191
+ if (opts && opts.down === false)
192
+ return;
193
+ const kc = ANDROID_KEYCODE[code];
194
+ if (kc !== undefined)
195
+ await driver.pressKeyEvent(kc);
196
+ },
197
+ async button(name) {
198
+ const kc = ANDROID_KEYCODE[name];
199
+ if (kc !== undefined)
200
+ await driver.pressKeyEvent(kc);
201
+ },
202
+ };
203
+ }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Streaming device-input protocol (conductor ⇄ Argus).
4
+ *
5
+ * A persistent per-device WebSocket carries pointer/key/button/scroll/remote
6
+ * frames so continuous drags and fast typing stay low-latency, instead of the
7
+ * per-event HTTP the web driver uses. Conductor owns coord→device translation
8
+ * and keymaps; the client streams normalized 0..1 coordinates.
9
+ *
10
+ * See docs/device-input-migration.md for the design.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.INPUT_PROTOCOL_VERSION = void 0;
14
+ exports.decodeClientFrame = decodeClientFrame;
15
+ exports.INPUT_PROTOCOL_VERSION = 1;
16
+ const CLIENT_FRAME_TYPES = new Set([
17
+ 'select',
18
+ 'pointer',
19
+ 'key',
20
+ 'text',
21
+ 'button',
22
+ 'scroll',
23
+ 'tvremote',
24
+ ]);
25
+ /** Parse and shallow-validate one text frame. Returns null on malformed input. */
26
+ function decodeClientFrame(raw) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (typeof parsed !== 'object' || parsed === null)
35
+ return null;
36
+ const t = parsed.t;
37
+ if (typeof t !== 'string' || !CLIENT_FRAME_TYPES.has(t))
38
+ return null;
39
+ return parsed;
40
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InputRouter = void 0;
4
+ /** Movement below this (normalized) counts as a tap, not a drag. */
5
+ const TAP_EPSILON = 0.01;
6
+ /** Default scroll gesture duration. */
7
+ const SCROLL_DURATION_MS = 200;
8
+ class InputRouter {
9
+ constructor(backend, opts = {}) {
10
+ this.backend = backend;
11
+ this.opts = opts;
12
+ this.sessions = new Map();
13
+ this.liveOpen = new Set();
14
+ this.now = opts.now ?? (() => Date.now());
15
+ }
16
+ get liveDrag() {
17
+ return this.opts.livePointer ? 'native' : 'buffered';
18
+ }
19
+ capabilities() {
20
+ return this.backend.capabilities(this.liveDrag);
21
+ }
22
+ /** Dispatch one frame. `select` is handled by the server, not here. */
23
+ async dispatch(frame) {
24
+ switch (frame.t) {
25
+ case 'pointer':
26
+ return this.handlePointer(frame.id ?? 0, frame.phase, frame.x, frame.y);
27
+ case 'key':
28
+ return this.backend.key(frame.code, { down: frame.down, mods: frame.mods });
29
+ case 'text':
30
+ return this.backend.text(frame.value);
31
+ case 'button':
32
+ return this.backend.button(frame.name, frame.holdMs);
33
+ case 'scroll':
34
+ return this.backend.swipe(frame.x, frame.y, frame.x - frame.dx, frame.y - frame.dy, SCROLL_DURATION_MS);
35
+ case 'tvremote':
36
+ return this.backend.button(frame.button, frame.holdMs);
37
+ case 'select':
38
+ return;
39
+ }
40
+ }
41
+ async handlePointer(id, phase, nx, ny) {
42
+ // Native held-touch path: stream straight through.
43
+ if (this.opts.livePointer) {
44
+ if (phase === 'down')
45
+ this.liveOpen.add(id);
46
+ if (phase === 'up' || phase === 'cancel')
47
+ this.liveOpen.delete(id);
48
+ return this.opts.livePointer.pointer(id, phase, nx, ny);
49
+ }
50
+ // Buffered path.
51
+ const t = this.now();
52
+ if (phase === 'down') {
53
+ this.sessions.set(id, {
54
+ steps: [{ nx, ny, tMs: 0 }],
55
+ lastStepAt: t,
56
+ startNx: nx,
57
+ startNy: ny,
58
+ moved: false,
59
+ done: false,
60
+ canceled: false,
61
+ });
62
+ return;
63
+ }
64
+ const s = this.sessions.get(id);
65
+ if (!s)
66
+ return; // move/up without a down — ignore
67
+ s.steps.push({ nx, ny, tMs: Math.max(0, t - s.lastStepAt) });
68
+ s.lastStepAt = t;
69
+ if (Math.hypot(nx - s.startNx, ny - s.startNy) > TAP_EPSILON)
70
+ s.moved = true;
71
+ if (phase === 'up' || phase === 'cancel') {
72
+ s.done = true;
73
+ s.canceled = phase === 'cancel';
74
+ if ([...this.sessions.values()].every((x) => x.done))
75
+ await this.flush();
76
+ }
77
+ }
78
+ async flush() {
79
+ const sessions = [...this.sessions.values()];
80
+ this.sessions.clear();
81
+ const active = sessions.filter((s) => !s.canceled);
82
+ if (active.length === 0)
83
+ return;
84
+ if (active.length === 1 && !active[0].moved) {
85
+ await this.backend.tap(active[0].startNx, active[0].startNy);
86
+ return;
87
+ }
88
+ await this.backend.gesture(active.map((s) => ({ steps: s.steps })));
89
+ }
90
+ /**
91
+ * Release any touch still held when the socket closes, so no finger is left
92
+ * stuck down. Buffered sessions flush as-is; live touches get a cancel.
93
+ */
94
+ async onClose() {
95
+ if (this.opts.livePointer) {
96
+ const open = [...this.liveOpen];
97
+ this.liveOpen.clear();
98
+ for (const id of open) {
99
+ await this.opts.livePointer.pointer(id, 'cancel', 0, 0).catch(() => { });
100
+ }
101
+ return;
102
+ }
103
+ if (this.sessions.size > 0) {
104
+ for (const s of this.sessions.values())
105
+ s.done = true;
106
+ await this.flush().catch(() => { });
107
+ }
108
+ }
109
+ }
110
+ exports.InputRouter = InputRouter;
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startInputServer = startInputServer;
4
+ /**
5
+ * Streaming input WebSocket server (one per device daemon).
6
+ *
7
+ * Bound to loopback; the host IDE opens one long-lived socket per active device
8
+ * and streams pointer/key/button frames. Frames are processed in-order on a
9
+ * single-consumer queue per connection; consecutive pointer moves for the same
10
+ * finger are coalesced so a fast drag never backs up the injector. Phase
11
+ * transitions (down/up/cancel) are never dropped.
12
+ */
13
+ const ws_1 = require("ws");
14
+ const input_protocol_js_1 = require("./input-protocol.js");
15
+ const STAT_INTERVAL_MS = 500;
16
+ function send(ws, frame) {
17
+ if (ws.readyState === ws_1.WebSocket.OPEN)
18
+ ws.send(JSON.stringify(frame));
19
+ }
20
+ function startInputServer(opts) {
21
+ const host = opts.host ?? '127.0.0.1';
22
+ const dlog = opts.dlog ?? (() => { });
23
+ return new Promise((resolve, reject) => {
24
+ const wss = new ws_1.WebSocketServer({ host, port: opts.port });
25
+ wss.on('error', reject);
26
+ wss.on('listening', () => {
27
+ const addr = wss.address();
28
+ const boundPort = typeof addr === 'object' && addr ? addr.port : opts.port;
29
+ dlog(`input socket ready at ws://${host}:${boundPort}/input (${opts.platform})`);
30
+ resolve({
31
+ port: boundPort,
32
+ close: () => new Promise((res) => {
33
+ for (const client of wss.clients)
34
+ client.close();
35
+ wss.close(() => res());
36
+ }),
37
+ });
38
+ });
39
+ wss.on('connection', (ws) => {
40
+ handleConnection(ws, opts, dlog).catch((err) => {
41
+ dlog(`input connection error: ${err instanceof Error ? err.message : String(err)}`);
42
+ try {
43
+ ws.close();
44
+ }
45
+ catch {
46
+ /* ok */
47
+ }
48
+ });
49
+ });
50
+ });
51
+ }
52
+ async function handleConnection(ws, opts, dlog) {
53
+ const router = await opts.makeRouter();
54
+ send(ws, {
55
+ t: 'hello',
56
+ protocol: input_protocol_js_1.INPUT_PROTOCOL_VERSION,
57
+ device: opts.device,
58
+ platform: opts.platform,
59
+ capabilities: router.capabilities(),
60
+ });
61
+ const queue = [];
62
+ let draining = false;
63
+ let dropped = 0;
64
+ const statTimer = setInterval(() => {
65
+ if (dropped > 0) {
66
+ send(ws, { t: 'stat', dropped });
67
+ dropped = 0;
68
+ }
69
+ }, STAT_INTERVAL_MS);
70
+ statTimer.unref?.();
71
+ const pump = async () => {
72
+ if (draining)
73
+ return;
74
+ draining = true;
75
+ while (queue.length > 0) {
76
+ const frame = queue.shift();
77
+ try {
78
+ await router.dispatch(frame);
79
+ if (frame.t !== 'select' && frame.ack && typeof frame.seq === 'number') {
80
+ send(ws, { t: 'ok', seq: frame.seq });
81
+ }
82
+ }
83
+ catch (err) {
84
+ send(ws, {
85
+ t: 'error',
86
+ seq: frame.t !== 'select' ? frame.seq : undefined,
87
+ code: 'dispatch_failed',
88
+ msg: err instanceof Error ? err.message : String(err),
89
+ });
90
+ }
91
+ }
92
+ draining = false;
93
+ };
94
+ ws.on('message', (data) => {
95
+ const frame = (0, input_protocol_js_1.decodeClientFrame)(data.toString());
96
+ if (!frame) {
97
+ send(ws, { t: 'error', code: 'bad_frame', msg: 'malformed or unknown frame' });
98
+ return;
99
+ }
100
+ if (frame.t === 'select')
101
+ return; // handshake reply; nothing to enqueue
102
+ // Coalesce consecutive moves for the same finger — keep only the latest.
103
+ if (frame.t === 'pointer' && frame.phase === 'move') {
104
+ const last = queue[queue.length - 1];
105
+ if (last &&
106
+ last.t === 'pointer' &&
107
+ last.phase === 'move' &&
108
+ (last.id ?? 0) === (frame.id ?? 0)) {
109
+ queue[queue.length - 1] = frame;
110
+ dropped++;
111
+ return;
112
+ }
113
+ }
114
+ queue.push(frame);
115
+ void pump();
116
+ });
117
+ ws.on('close', () => {
118
+ clearInterval(statTimer);
119
+ void router.onClose();
120
+ });
121
+ ws.on('error', (err) => {
122
+ dlog(`input socket error: ${err instanceof Error ? err.message : String(err)}`);
123
+ });
124
+ }
@@ -22,7 +22,12 @@ const protocol_js_1 = require("./protocol.js");
22
22
  const sdk_js_1 = require("../android/sdk.js");
23
23
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
24
24
  const android_js_1 = require("../drivers/android.js");
25
+ const ios_js_1 = require("../drivers/ios.js");
25
26
  const web_server_js_1 = require("./web-server.js");
27
+ const input_server_js_1 = require("./input-server.js");
28
+ const input_router_js_1 = require("./input-router.js");
29
+ const input_backends_js_1 = require("./input-backends.js");
30
+ const ios_hid_js_1 = require("../drivers/ios-hid.js");
26
31
  const log_collector_js_1 = require("./log-collector.js");
27
32
  const session_js_1 = require("../session.js");
28
33
  const sessionName = process.argv[2] ?? 'default';
@@ -78,6 +83,62 @@ function dlog(msg) {
78
83
  let driverPort = 1075;
79
84
  let driverPlatform = 'ios';
80
85
  let logCollector = null;
86
+ let inputServer = null;
87
+ let inputPort = null;
88
+ let hidClient = null;
89
+ /**
90
+ * Start the streaming-input WebSocket server for the current device, once its
91
+ * driver is up. iOS/tvOS/Android only — web keeps its per-event REST path.
92
+ * Each connection gets a fresh router (its own pointer state) over a shared
93
+ * driver instance.
94
+ */
95
+ async function startInputServerForPlatform() {
96
+ if (inputServer)
97
+ return;
98
+ if (driverPlatform !== 'ios' && driverPlatform !== 'tvos' && driverPlatform !== 'android')
99
+ return;
100
+ let makeBackend;
101
+ let livePointer;
102
+ if (driverPlatform === 'android') {
103
+ const driver = new android_js_1.AndroidDriver(sessionName, driverPort);
104
+ await driver.connect();
105
+ makeBackend = () => (0, input_backends_js_1.androidBackend)(driver);
106
+ }
107
+ else {
108
+ const driver = new ios_js_1.IOSDriver(driverPort, '127.0.0.1', sessionName, driverPlatform);
109
+ makeBackend = () => (0, input_backends_js_1.iosBackend)(driver);
110
+ // Opt-in native held-touch backend for live drags (iOS only; single-touch).
111
+ if (driverPlatform === 'ios' && process.env.CONDUCTOR_IOS_HID === '1') {
112
+ const bin = await (0, bootstrap_js_1.getHidBinaryPath)();
113
+ if (bin) {
114
+ const client = new ios_hid_js_1.IOSHidClient(bin, sessionName);
115
+ client.start();
116
+ if (await client.ping().catch(() => false)) {
117
+ hidClient = client;
118
+ livePointer = client.asLivePointer();
119
+ dlog('iOS HID injector active — live drags use CoreSimulator HID');
120
+ }
121
+ else {
122
+ client.stop();
123
+ dlog('iOS HID injector present but not responding — falling back to buffered drag');
124
+ }
125
+ }
126
+ else {
127
+ dlog('CONDUCTOR_IOS_HID=1 but no HID binary built — falling back to buffered drag');
128
+ }
129
+ }
130
+ }
131
+ const port = await (0, bootstrap_js_1.getInputPort)(sessionName);
132
+ inputServer = await (0, input_server_js_1.startInputServer)({
133
+ port,
134
+ device: sessionName,
135
+ platform: driverPlatform,
136
+ makeRouter: () => new input_router_js_1.InputRouter(makeBackend(), { livePointer }),
137
+ dlog,
138
+ });
139
+ inputPort = port;
140
+ dlog(`input server listening on ${port}`);
141
+ }
81
142
  const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
82
143
  let _restartInProgress = false;
83
144
  let _driverStarted = false;
@@ -169,6 +230,19 @@ async function main() {
169
230
  logCollector.stop();
170
231
  logCollector = null;
171
232
  }
233
+ if (inputServer) {
234
+ try {
235
+ await inputServer.close();
236
+ }
237
+ catch {
238
+ /* ok */
239
+ }
240
+ inputServer = null;
241
+ }
242
+ if (hidClient) {
243
+ hidClient.stop();
244
+ hidClient = null;
245
+ }
172
246
  try {
173
247
  fs_1.default.unlinkSync(SOCKET_PATH);
174
248
  }
@@ -291,6 +365,7 @@ async function main() {
291
365
  ok: true,
292
366
  platform: driverPlatform,
293
367
  driverPort,
368
+ inputPort,
294
369
  cdpUrl: cdpUrl ?? null,
295
370
  cdpTargetId: cdpTargetId ?? null,
296
371
  chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
@@ -336,6 +411,15 @@ async function main() {
336
411
  else {
337
412
  await startDriverForPlatform(platform);
338
413
  }
414
+ // Start the streaming-input socket once the driver is up.
415
+ if (_driverStarted) {
416
+ try {
417
+ await startInputServerForPlatform();
418
+ }
419
+ catch (err) {
420
+ dlog(`Input server startup error: ${err instanceof Error ? err.message : String(err)}`);
421
+ }
422
+ }
339
423
  // Start collecting logs once the driver is (or was already) running.
340
424
  if (_driverStarted) {
341
425
  try {
@@ -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'));