@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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* H.264 Annex B streaming parser (daemon side).
|
|
4
|
+
*
|
|
5
|
+
* Splits the raw Annex B byte stream from the capture backend into access units,
|
|
6
|
+
* extracts SPS/PPS to build the stream config, and preserves each access unit
|
|
7
|
+
* **in Annex B form** (start codes intact) for forwarding to subscribers — the
|
|
8
|
+
* wire contract is "config + a sequence of Annex B access units, keyframe-led".
|
|
9
|
+
*
|
|
10
|
+
* Ported from Argus's electron/services/simulator/h264-parser.ts; the SPS
|
|
11
|
+
* bit-reader is identical, but access units are emitted as Annex B rather than
|
|
12
|
+
* converted to AVCC length-prefix (that conversion is left to WebCodecs clients).
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.H264AnnexBParser = void 0;
|
|
16
|
+
const NAL_SLICE = 1;
|
|
17
|
+
const NAL_IDR = 5;
|
|
18
|
+
const NAL_SPS = 7;
|
|
19
|
+
const NAL_PPS = 8;
|
|
20
|
+
// ── Exp-Golomb reader (minimal, for SPS parsing) ────────────────────────────
|
|
21
|
+
class ExpGolombReader {
|
|
22
|
+
constructor(data) {
|
|
23
|
+
this.data = data;
|
|
24
|
+
this.byteOffset = 0;
|
|
25
|
+
this.bitOffset = 0;
|
|
26
|
+
}
|
|
27
|
+
readBit() {
|
|
28
|
+
if (this.byteOffset >= this.data.length)
|
|
29
|
+
return 0;
|
|
30
|
+
const bit = (this.data[this.byteOffset] >> (7 - this.bitOffset)) & 1;
|
|
31
|
+
this.bitOffset++;
|
|
32
|
+
if (this.bitOffset === 8) {
|
|
33
|
+
this.bitOffset = 0;
|
|
34
|
+
this.byteOffset++;
|
|
35
|
+
}
|
|
36
|
+
return bit;
|
|
37
|
+
}
|
|
38
|
+
readBits(n) {
|
|
39
|
+
let val = 0;
|
|
40
|
+
for (let i = 0; i < n; i++)
|
|
41
|
+
val = (val << 1) | this.readBit();
|
|
42
|
+
return val;
|
|
43
|
+
}
|
|
44
|
+
readUE() {
|
|
45
|
+
let zeros = 0;
|
|
46
|
+
while (this.readBit() === 0 && zeros < 31)
|
|
47
|
+
zeros++;
|
|
48
|
+
if (zeros === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
return (1 << zeros) - 1 + this.readBits(zeros);
|
|
51
|
+
}
|
|
52
|
+
readSE() {
|
|
53
|
+
const val = this.readUE();
|
|
54
|
+
return val % 2 === 0 ? -(val >> 1) : (val + 1) >> 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Parse the SPS fields needed for config. `body` is the NAL after the header byte. */
|
|
58
|
+
function parseSps(body) {
|
|
59
|
+
const r = new ExpGolombReader(body);
|
|
60
|
+
const profileIdc = r.readBits(8);
|
|
61
|
+
const constraintFlags = r.readBits(8);
|
|
62
|
+
const levelIdc = r.readBits(8);
|
|
63
|
+
r.readUE(); // seq_parameter_set_id
|
|
64
|
+
if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134].includes(profileIdc)) {
|
|
65
|
+
const chromaFormatIdc = r.readUE();
|
|
66
|
+
if (chromaFormatIdc === 3)
|
|
67
|
+
r.readBits(1);
|
|
68
|
+
r.readUE(); // bit_depth_luma_minus8
|
|
69
|
+
r.readUE(); // bit_depth_chroma_minus8
|
|
70
|
+
r.readBits(1); // qpprime_y_zero_transform_bypass_flag
|
|
71
|
+
if (r.readBits(1)) {
|
|
72
|
+
const count = chromaFormatIdc !== 3 ? 8 : 12;
|
|
73
|
+
for (let i = 0; i < count; i++) {
|
|
74
|
+
if (r.readBits(1)) {
|
|
75
|
+
const size = i < 6 ? 16 : 64;
|
|
76
|
+
let lastScale = 8;
|
|
77
|
+
let nextScale = 8;
|
|
78
|
+
for (let j = 0; j < size; j++) {
|
|
79
|
+
if (nextScale !== 0)
|
|
80
|
+
nextScale = (lastScale + r.readSE() + 256) % 256;
|
|
81
|
+
lastScale = nextScale === 0 ? lastScale : nextScale;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
r.readUE(); // log2_max_frame_num_minus4
|
|
88
|
+
const picOrderCntType = r.readUE();
|
|
89
|
+
if (picOrderCntType === 0) {
|
|
90
|
+
r.readUE();
|
|
91
|
+
}
|
|
92
|
+
else if (picOrderCntType === 1) {
|
|
93
|
+
r.readBits(1);
|
|
94
|
+
r.readSE();
|
|
95
|
+
r.readSE();
|
|
96
|
+
const n = r.readUE();
|
|
97
|
+
for (let i = 0; i < n; i++)
|
|
98
|
+
r.readSE();
|
|
99
|
+
}
|
|
100
|
+
r.readUE(); // max_num_ref_frames
|
|
101
|
+
r.readBits(1); // gaps_in_frame_num_value_allowed_flag
|
|
102
|
+
const picWidthInMbsMinus1 = r.readUE();
|
|
103
|
+
const picHeightInMapUnitsMinus1 = r.readUE();
|
|
104
|
+
const frameMbsOnlyFlag = r.readBits(1);
|
|
105
|
+
let width = (picWidthInMbsMinus1 + 1) * 16;
|
|
106
|
+
let height = (2 - frameMbsOnlyFlag) * (picHeightInMapUnitsMinus1 + 1) * 16;
|
|
107
|
+
if (!frameMbsOnlyFlag)
|
|
108
|
+
r.readBits(1);
|
|
109
|
+
r.readBits(1); // direct_8x8_inference_flag
|
|
110
|
+
if (r.readBits(1)) {
|
|
111
|
+
// frame_cropping_flag — 4:2:0 crop units are 2px
|
|
112
|
+
const cl = r.readUE();
|
|
113
|
+
const cr = r.readUE();
|
|
114
|
+
const ct = r.readUE();
|
|
115
|
+
const cb = r.readUE();
|
|
116
|
+
width -= (cl + cr) * 2;
|
|
117
|
+
height -= (ct + cb) * 2;
|
|
118
|
+
}
|
|
119
|
+
return { profileIdc, constraintFlags, levelIdc, width, height };
|
|
120
|
+
}
|
|
121
|
+
function buildCodecString(info) {
|
|
122
|
+
const pp = info.profileIdc.toString(16).padStart(2, '0');
|
|
123
|
+
const cc = info.constraintFlags.toString(16).padStart(2, '0');
|
|
124
|
+
const ll = info.levelIdc.toString(16).padStart(2, '0');
|
|
125
|
+
return `avc1.${pp}${cc}${ll}`;
|
|
126
|
+
}
|
|
127
|
+
/** AVCDecoderConfigurationRecord from raw SPS/PPS NAL bodies (with NAL header byte). */
|
|
128
|
+
function buildAvcC(sps, pps) {
|
|
129
|
+
const buf = Buffer.alloc(11 + sps.length + pps.length);
|
|
130
|
+
buf[0] = 1; // configurationVersion
|
|
131
|
+
buf[1] = sps[1]; // profile
|
|
132
|
+
buf[2] = sps[2]; // compatibility
|
|
133
|
+
buf[3] = sps[3]; // level
|
|
134
|
+
buf[4] = 0xff; // lengthSizeMinusOne = 3
|
|
135
|
+
buf[5] = 0xe1; // numOfSPS = 1
|
|
136
|
+
buf.writeUInt16BE(sps.length, 6);
|
|
137
|
+
Buffer.from(sps).copy(buf, 8);
|
|
138
|
+
const off = 8 + sps.length;
|
|
139
|
+
buf[off] = 1; // numOfPPS
|
|
140
|
+
buf.writeUInt16BE(pps.length, off + 1);
|
|
141
|
+
Buffer.from(pps).copy(buf, off + 3);
|
|
142
|
+
return buf;
|
|
143
|
+
}
|
|
144
|
+
/** Find the next Annex B start code (00 00 01 or 00 00 00 01). */
|
|
145
|
+
function findStartCode(buf, offset) {
|
|
146
|
+
for (let i = offset; i < buf.length - 2; i++) {
|
|
147
|
+
if (buf[i] === 0 && buf[i + 1] === 0) {
|
|
148
|
+
if (buf[i + 2] === 1)
|
|
149
|
+
return { index: i, length: 3 };
|
|
150
|
+
if (buf[i + 2] === 0 && i + 3 < buf.length && buf[i + 3] === 1)
|
|
151
|
+
return { index: i, length: 4 };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Streaming Annex B parser. Feed raw capture bytes with push(); emits config
|
|
158
|
+
* once SPS+PPS are seen, then one Annex B access unit per VCL frame.
|
|
159
|
+
*/
|
|
160
|
+
class H264AnnexBParser {
|
|
161
|
+
constructor(cbs, fps = 30) {
|
|
162
|
+
this.cbs = cbs;
|
|
163
|
+
this.buffer = Buffer.alloc(0);
|
|
164
|
+
this.sps = null;
|
|
165
|
+
this.pps = null;
|
|
166
|
+
this.config = null;
|
|
167
|
+
// NALs (each Annex-B, start-code prefixed) buffered for the current access unit.
|
|
168
|
+
this.au = [];
|
|
169
|
+
this.auIsKey = false;
|
|
170
|
+
this.fps = fps;
|
|
171
|
+
}
|
|
172
|
+
reset() {
|
|
173
|
+
this.buffer = Buffer.alloc(0);
|
|
174
|
+
this.sps = null;
|
|
175
|
+
this.pps = null;
|
|
176
|
+
this.config = null;
|
|
177
|
+
this.au = [];
|
|
178
|
+
this.auIsKey = false;
|
|
179
|
+
}
|
|
180
|
+
push(chunk) {
|
|
181
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
182
|
+
this.processBuffer();
|
|
183
|
+
}
|
|
184
|
+
processBuffer() {
|
|
185
|
+
let sc = findStartCode(this.buffer, 0);
|
|
186
|
+
if (!sc)
|
|
187
|
+
return;
|
|
188
|
+
while (true) {
|
|
189
|
+
const next = findStartCode(this.buffer, sc.index + sc.length);
|
|
190
|
+
if (!next)
|
|
191
|
+
break; // incomplete NAL — wait for more data
|
|
192
|
+
const nal = this.buffer.subarray(sc.index + sc.length, next.index);
|
|
193
|
+
// Keep the whole unit including start code so subscribers get Annex B.
|
|
194
|
+
const withStartCode = this.buffer.subarray(sc.index, next.index);
|
|
195
|
+
this.processNal(nal, withStartCode);
|
|
196
|
+
sc = next;
|
|
197
|
+
}
|
|
198
|
+
// Retain the trailing (unterminated) NAL for the next push.
|
|
199
|
+
if (sc.index > 0)
|
|
200
|
+
this.buffer = Buffer.from(this.buffer.subarray(sc.index));
|
|
201
|
+
// Guard against unbounded growth on a stuck stream.
|
|
202
|
+
if (this.buffer.length > 4 * 1024 * 1024) {
|
|
203
|
+
this.buffer = Buffer.from(this.buffer.subarray(-1024 * 1024));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
processNal(nal, withStartCode) {
|
|
207
|
+
if (nal.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
const nalType = nal[0] & 0x1f;
|
|
210
|
+
if (nalType === NAL_SPS) {
|
|
211
|
+
this.sps = Buffer.from(nal);
|
|
212
|
+
this.emitConfigIfReady();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (nalType === NAL_PPS) {
|
|
216
|
+
this.pps = Buffer.from(nal);
|
|
217
|
+
this.emitConfigIfReady();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (nalType === NAL_IDR || nalType === NAL_SLICE) {
|
|
221
|
+
// Flush any previously buffered AU before starting this VCL frame.
|
|
222
|
+
if (this.au.length > 0)
|
|
223
|
+
this.emitAccessUnit();
|
|
224
|
+
this.auIsKey = nalType === NAL_IDR;
|
|
225
|
+
this.au.push(Buffer.from(withStartCode));
|
|
226
|
+
this.emitAccessUnit();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// SEI / AUD / other — attach to the current access unit.
|
|
230
|
+
this.au.push(Buffer.from(withStartCode));
|
|
231
|
+
}
|
|
232
|
+
emitAccessUnit() {
|
|
233
|
+
if (this.au.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
if (!this.config) {
|
|
236
|
+
// Can't decode without config yet — drop until SPS/PPS arrive.
|
|
237
|
+
this.au = [];
|
|
238
|
+
this.auIsKey = false;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const data = this.au.length === 1 ? this.au[0] : Buffer.concat(this.au);
|
|
242
|
+
this.cbs.onFrame(data, this.auIsKey);
|
|
243
|
+
this.au = [];
|
|
244
|
+
this.auIsKey = false;
|
|
245
|
+
}
|
|
246
|
+
emitConfigIfReady() {
|
|
247
|
+
if (this.config || !this.sps || !this.pps)
|
|
248
|
+
return;
|
|
249
|
+
const info = parseSps(this.sps.subarray(1));
|
|
250
|
+
this.config = {
|
|
251
|
+
codec: 'h264',
|
|
252
|
+
width: info.width,
|
|
253
|
+
height: info.height,
|
|
254
|
+
rotation: 0,
|
|
255
|
+
fps: this.fps,
|
|
256
|
+
codecString: buildCodecString(info),
|
|
257
|
+
sps: this.sps,
|
|
258
|
+
pps: this.pps,
|
|
259
|
+
avcC: buildAvcC(this.sps, this.pps),
|
|
260
|
+
};
|
|
261
|
+
this.cbs.onConfig(this.config);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
exports.H264AnnexBParser = H264AnnexBParser;
|
|
@@ -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;
|