@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.
- 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/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/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 +141 -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 +1 -1
- package/skills/conductor-inspect/SKILL.md +43 -0
- package/skills/conductor-metro-debugger/SKILL.md +10 -0
|
@@ -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
|
+
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -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'));
|