@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.
@@ -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,14 @@ 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");
31
+ const video_server_js_1 = require("./video-server.js");
32
+ const video_source_js_1 = require("./video-source.js");
26
33
  const log_collector_js_1 = require("./log-collector.js");
27
34
  const session_js_1 = require("../session.js");
28
35
  const sessionName = process.argv[2] ?? 'default';
@@ -78,6 +85,91 @@ function dlog(msg) {
78
85
  let driverPort = 1075;
79
86
  let driverPlatform = 'ios';
80
87
  let logCollector = null;
88
+ let inputServer = null;
89
+ let inputPort = null;
90
+ let hidClient = null;
91
+ let videoServer = null;
92
+ let streamPort = null;
93
+ /**
94
+ * Start the streaming-input WebSocket server for the current device, once its
95
+ * driver is up. iOS/tvOS/Android only — web keeps its per-event REST path.
96
+ * Each connection gets a fresh router (its own pointer state) over a shared
97
+ * driver instance.
98
+ */
99
+ async function startInputServerForPlatform() {
100
+ if (inputServer)
101
+ return;
102
+ if (driverPlatform !== 'ios' && driverPlatform !== 'tvos' && driverPlatform !== 'android')
103
+ return;
104
+ let makeBackend;
105
+ let livePointer;
106
+ if (driverPlatform === 'android') {
107
+ const driver = new android_js_1.AndroidDriver(sessionName, driverPort);
108
+ await driver.connect();
109
+ makeBackend = () => (0, input_backends_js_1.androidBackend)(driver);
110
+ }
111
+ else {
112
+ const driver = new ios_js_1.IOSDriver(driverPort, '127.0.0.1', sessionName, driverPlatform);
113
+ makeBackend = () => (0, input_backends_js_1.iosBackend)(driver);
114
+ // Opt-in native held-touch backend for live drags (iOS only; single-touch).
115
+ if (driverPlatform === 'ios' && process.env.CONDUCTOR_IOS_HID === '1') {
116
+ const bin = await (0, bootstrap_js_1.getHidBinaryPath)();
117
+ if (bin) {
118
+ const client = new ios_hid_js_1.IOSHidClient(bin, sessionName);
119
+ client.start();
120
+ if (await client.ping().catch(() => false)) {
121
+ hidClient = client;
122
+ livePointer = client.asLivePointer();
123
+ dlog('iOS HID injector active — live drags use CoreSimulator HID');
124
+ }
125
+ else {
126
+ client.stop();
127
+ dlog('iOS HID injector present but not responding — falling back to buffered drag');
128
+ }
129
+ }
130
+ else {
131
+ dlog('CONDUCTOR_IOS_HID=1 but no HID binary built — falling back to buffered drag');
132
+ }
133
+ }
134
+ }
135
+ const port = await (0, bootstrap_js_1.getInputPort)(sessionName);
136
+ inputServer = await (0, input_server_js_1.startInputServer)({
137
+ port,
138
+ device: sessionName,
139
+ platform: driverPlatform,
140
+ makeRouter: () => new input_router_js_1.InputRouter(makeBackend(), { livePointer }),
141
+ dlog,
142
+ });
143
+ inputPort = port;
144
+ dlog(`input server listening on ${port}`);
145
+ }
146
+ /**
147
+ * Start the streaming-video WebSocket server for the current device, once its
148
+ * driver is up. iOS/tvOS only for now (host-side SimulatorKit capture); the
149
+ * binary must be built/downloaded. Capture is lazy: it doesn't spawn until the
150
+ * first subscriber connects, and stops when the last one leaves.
151
+ */
152
+ async function startVideoServerForPlatform() {
153
+ if (videoServer)
154
+ return;
155
+ if (driverPlatform !== 'ios' && driverPlatform !== 'tvos')
156
+ return;
157
+ const binary = await (0, bootstrap_js_1.getCaptureBinaryPath)();
158
+ if (!binary) {
159
+ dlog('video capture binary not present — stream server disabled for this device');
160
+ return;
161
+ }
162
+ const port = await (0, bootstrap_js_1.getStreamPort)(sessionName);
163
+ videoServer = await (0, video_server_js_1.startVideoServer)({
164
+ port,
165
+ device: sessionName,
166
+ platform: driverPlatform,
167
+ makeSource: (hub) => new video_source_js_1.IOSCaptureSource(binary, sessionName, hub, dlog),
168
+ dlog,
169
+ });
170
+ streamPort = port;
171
+ dlog(`video server listening on ${port}`);
172
+ }
81
173
  const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
82
174
  let _restartInProgress = false;
83
175
  let _driverStarted = false;
@@ -169,6 +261,28 @@ async function main() {
169
261
  logCollector.stop();
170
262
  logCollector = null;
171
263
  }
264
+ if (inputServer) {
265
+ try {
266
+ await inputServer.close();
267
+ }
268
+ catch {
269
+ /* ok */
270
+ }
271
+ inputServer = null;
272
+ }
273
+ if (videoServer) {
274
+ try {
275
+ await videoServer.close();
276
+ }
277
+ catch {
278
+ /* ok */
279
+ }
280
+ videoServer = null;
281
+ }
282
+ if (hidClient) {
283
+ hidClient.stop();
284
+ hidClient = null;
285
+ }
172
286
  try {
173
287
  fs_1.default.unlinkSync(SOCKET_PATH);
174
288
  }
@@ -291,6 +405,8 @@ async function main() {
291
405
  ok: true,
292
406
  platform: driverPlatform,
293
407
  driverPort,
408
+ inputPort,
409
+ streamPort,
294
410
  cdpUrl: cdpUrl ?? null,
295
411
  cdpTargetId: cdpTargetId ?? null,
296
412
  chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
@@ -336,6 +452,21 @@ async function main() {
336
452
  else {
337
453
  await startDriverForPlatform(platform);
338
454
  }
455
+ // Start the streaming-input socket once the driver is up.
456
+ if (_driverStarted) {
457
+ try {
458
+ await startInputServerForPlatform();
459
+ }
460
+ catch (err) {
461
+ dlog(`Input server startup error: ${err instanceof Error ? err.message : String(err)}`);
462
+ }
463
+ try {
464
+ await startVideoServerForPlatform();
465
+ }
466
+ catch (err) {
467
+ dlog(`Video server startup error: ${err instanceof Error ? err.message : String(err)}`);
468
+ }
469
+ }
339
470
  // Start collecting logs once the driver is (or was already) running.
340
471
  if (_driverStarted) {
341
472
  try {
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * Fan-out hub for a single device's H.264 stream: one capture, N subscribers.
4
+ *
5
+ * Mirrors Argus's videoForward.ts. The last-seen config and the last keyframe
6
+ * access unit are cached so a late subscriber can configure its decoder and
7
+ * start rendering immediately, without waiting for the next natural keyframe.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.VideoHub = void 0;
11
+ class VideoHub {
12
+ constructor() {
13
+ this.listeners = new Set();
14
+ this.lastConfig = null;
15
+ this.lastKeyframe = null;
16
+ }
17
+ get subscriberCount() {
18
+ return this.listeners.size;
19
+ }
20
+ /**
21
+ * Subscribe. If a config (and keyframe) are already cached they are delivered
22
+ * synchronously so the decoder starts without waiting. Returns an unsubscribe.
23
+ */
24
+ subscribe(listener) {
25
+ this.listeners.add(listener);
26
+ if (this.lastConfig) {
27
+ listener.onConfig(this.lastConfig);
28
+ if (this.lastKeyframe)
29
+ listener.onFrame(this.lastKeyframe, true);
30
+ }
31
+ return () => this.listeners.delete(listener);
32
+ }
33
+ emitConfig(config) {
34
+ this.lastConfig = config;
35
+ // A config change invalidates the cached keyframe (dimensions may differ).
36
+ this.lastKeyframe = null;
37
+ for (const l of this.listeners)
38
+ l.onConfig(config);
39
+ }
40
+ emitFrame(annexB, keyFrame) {
41
+ if (keyFrame)
42
+ this.lastKeyframe = annexB;
43
+ for (const l of this.listeners)
44
+ l.onFrame(annexB, keyFrame);
45
+ }
46
+ /** Current coded dimensions, if known. */
47
+ dims() {
48
+ return this.lastConfig
49
+ ? { width: this.lastConfig.width, height: this.lastConfig.height }
50
+ : null;
51
+ }
52
+ /** Drop cached state when capture ends. */
53
+ clear() {
54
+ this.lastConfig = null;
55
+ this.lastKeyframe = null;
56
+ }
57
+ }
58
+ exports.VideoHub = VideoHub;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Streaming device-video protocol (conductor ⇄ subscribers).
4
+ *
5
+ * The capture counterpart to input-protocol.ts. A persistent per-device
6
+ * WebSocket carries a low-latency H.264 stream: on connect the server sends one
7
+ * JSON `config` frame (codec + dimensions + SPS/PPS), then a sequence of
8
+ * **binary** frames, each a single H.264 Annex B access unit (keyframe-led). One
9
+ * capture feeds N subscribers (fan-out); a late joiner always gets the cached
10
+ * config + a fresh keyframe immediately.
11
+ *
12
+ * Web devices advertise a different codec (JPEG over CDP screencast) in the same
13
+ * `config` frame so clients pick a decoder from the wire, not the URL.
14
+ *
15
+ * See docs/device-video-stream.md for the design.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.VIDEO_PROTOCOL_VERSION = void 0;
19
+ exports.toConfigFrame = toConfigFrame;
20
+ exports.VIDEO_PROTOCOL_VERSION = 1;
21
+ /** Serialize an in-daemon config into the JSON frame sent to a subscriber. */
22
+ function toConfigFrame(cfg, device, platform) {
23
+ return {
24
+ t: 'config',
25
+ protocol: exports.VIDEO_PROTOCOL_VERSION,
26
+ device,
27
+ platform,
28
+ codec: cfg.codec,
29
+ width: cfg.width,
30
+ height: cfg.height,
31
+ rotation: cfg.rotation,
32
+ fps: cfg.fps,
33
+ codecString: cfg.codecString,
34
+ sps: cfg.sps ? cfg.sps.toString('base64') : undefined,
35
+ pps: cfg.pps ? cfg.pps.toString('base64') : undefined,
36
+ avcC: cfg.avcC ? cfg.avcC.toString('base64') : undefined,
37
+ };
38
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startVideoServer = startVideoServer;
4
+ /**
5
+ * Streaming video WebSocket server (one per device daemon).
6
+ *
7
+ * The capture counterpart to input-server.ts. Bound to loopback; N clients open
8
+ * a socket and receive one JSON `config` frame followed by binary H.264 Annex B
9
+ * access units. All subscribers share one capture via a VideoHub — the first
10
+ * connection starts the capture backend, the last one out stops it. A late
11
+ * joiner gets the cached config + keyframe immediately (hub responsibility).
12
+ */
13
+ const ws_1 = require("ws");
14
+ const video_protocol_js_1 = require("./video-protocol.js");
15
+ const video_hub_js_1 = require("./video-hub.js");
16
+ function sendJson(ws, frame) {
17
+ if (ws.readyState === ws_1.WebSocket.OPEN)
18
+ ws.send(JSON.stringify(frame));
19
+ }
20
+ function startVideoServer(opts) {
21
+ const host = opts.host ?? '127.0.0.1';
22
+ const dlog = opts.dlog ?? (() => { });
23
+ const hub = new video_hub_js_1.VideoHub();
24
+ const source = opts.makeSource(hub);
25
+ // Serialize start/stop so a rapid connect/disconnect churn can't race the
26
+ // capture backend into an inconsistent state.
27
+ let lifecycle = Promise.resolve();
28
+ const runExclusive = (fn) => {
29
+ lifecycle = lifecycle.then(fn, fn);
30
+ return lifecycle;
31
+ };
32
+ return new Promise((resolve, reject) => {
33
+ const wss = new ws_1.WebSocketServer({ host, port: opts.port });
34
+ wss.on('error', reject);
35
+ wss.on('listening', () => {
36
+ const addr = wss.address();
37
+ const boundPort = typeof addr === 'object' && addr ? addr.port : opts.port;
38
+ dlog(`video socket ready at ws://${host}:${boundPort}/stream (${opts.platform})`);
39
+ resolve({
40
+ port: boundPort,
41
+ hub,
42
+ close: () => runExclusive(async () => {
43
+ for (const client of wss.clients)
44
+ client.close();
45
+ await source.stop().catch(() => { });
46
+ await new Promise((res) => wss.close(() => res()));
47
+ }),
48
+ });
49
+ });
50
+ wss.on('connection', (ws) => {
51
+ const listener = {
52
+ onConfig: (config) => sendJson(ws, (0, video_protocol_js_1.toConfigFrame)(config, opts.device, opts.platform)),
53
+ onFrame: (annexB, _keyFrame) => {
54
+ if (ws.readyState === ws_1.WebSocket.OPEN)
55
+ ws.send(annexB);
56
+ },
57
+ };
58
+ const unsubscribe = hub.subscribe(listener);
59
+ const wasFirst = hub.subscriberCount === 1;
60
+ if (wasFirst) {
61
+ void runExclusive(async () => {
62
+ try {
63
+ await source.start();
64
+ }
65
+ catch (err) {
66
+ dlog(`capture start error: ${err instanceof Error ? err.message : String(err)}`);
67
+ sendJson(ws, {
68
+ t: 'notice',
69
+ code: 'capture_failed',
70
+ msg: 'capture backend failed to start',
71
+ });
72
+ }
73
+ });
74
+ }
75
+ const teardown = () => {
76
+ unsubscribe();
77
+ if (hub.subscriberCount === 0) {
78
+ void runExclusive(() => source.stop().catch(() => { }));
79
+ }
80
+ };
81
+ ws.on('close', teardown);
82
+ ws.on('error', (err) => {
83
+ dlog(`video socket error: ${err instanceof Error ? err.message : String(err)}`);
84
+ });
85
+ // Clients are pure subscribers; inbound messages are ignored.
86
+ });
87
+ });
88
+ }
@@ -0,0 +1,142 @@
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.IOSCaptureSource = void 0;
7
+ /**
8
+ * Capture sources — the producer half of the video stream. A source drives one
9
+ * platform's capture and feeds decoded config/access-units into a VideoHub. The
10
+ * video server ref-counts subscribers and calls start()/stop() so exactly one
11
+ * capture runs while ≥1 subscriber is connected (last one out stops it).
12
+ *
13
+ * iOS/tvOS: spawns the host-side `conductor-capture` binary (packages/ios-capture),
14
+ * which captures the Simulator framebuffer via SimulatorKit, VideoToolbox-encodes
15
+ * H.264, and serves a raw Annex B TCP stream. We connect to that port, parse the
16
+ * elementary stream, and forward.
17
+ */
18
+ const child_process_1 = require("child_process");
19
+ const net_1 = __importDefault(require("net"));
20
+ const readline_1 = __importDefault(require("readline"));
21
+ const h264_annexb_js_1 = require("./h264-annexb.js");
22
+ /** Restart the capture backend at most this often if the TCP stream drops. */
23
+ const RESTART_BACKOFF_MS = 1000;
24
+ class IOSCaptureSource {
25
+ constructor(binaryPath, udid, hub, dlog = () => { }) {
26
+ this.binaryPath = binaryPath;
27
+ this.udid = udid;
28
+ this.hub = hub;
29
+ this.dlog = dlog;
30
+ this.proc = null;
31
+ this.rl = null;
32
+ this.socket = null;
33
+ this.parser = null;
34
+ this.pending = [];
35
+ this.active = false;
36
+ this.restarting = false;
37
+ }
38
+ async start() {
39
+ if (this.active)
40
+ return;
41
+ this.active = true;
42
+ await this.spawnAndCapture();
43
+ }
44
+ async spawnAndCapture() {
45
+ this.proc = (0, child_process_1.spawn)(this.binaryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
46
+ this.rl = readline_1.default.createInterface({ input: this.proc.stdout });
47
+ this.rl.on('line', (line) => {
48
+ const resolve = this.pending.shift();
49
+ if (!resolve)
50
+ return;
51
+ try {
52
+ resolve(JSON.parse(line));
53
+ }
54
+ catch {
55
+ resolve({ ok: false, error: `bad response: ${line}` });
56
+ }
57
+ });
58
+ this.proc.on('exit', (code) => {
59
+ this.dlog(`capture backend exited (code=${code ?? '?'})`);
60
+ this.proc = null;
61
+ this.rl = null;
62
+ while (this.pending.length)
63
+ this.pending.shift()({ ok: false, error: 'capture exited' });
64
+ this.maybeRestart();
65
+ });
66
+ const res = await this.send({ cmd: 'start_capture', udid: this.udid });
67
+ if (!res.ok || typeof res.port !== 'number') {
68
+ throw new Error(`start_capture failed: ${res.error ?? 'no port returned'}`);
69
+ }
70
+ this.connectStream(res.port);
71
+ this.dlog(`capture started, streaming H.264 from 127.0.0.1:${res.port}`);
72
+ }
73
+ connectStream(port) {
74
+ const parser = new h264_annexb_js_1.H264AnnexBParser({
75
+ onConfig: (config) => {
76
+ this.dlog(`H.264 config ${config.codecString} ${config.width}x${config.height}`);
77
+ this.hub.emitConfig(config);
78
+ },
79
+ onFrame: (annexB, keyFrame) => this.hub.emitFrame(annexB, keyFrame),
80
+ });
81
+ this.parser = parser;
82
+ const socket = net_1.default.connect(port, '127.0.0.1', () => {
83
+ this.dlog(`connected to capture stream on ${port}`);
84
+ });
85
+ socket.on('data', (chunk) => parser.push(chunk));
86
+ socket.on('error', (err) => this.dlog(`capture stream error: ${err.message}`));
87
+ socket.on('close', () => {
88
+ this.dlog('capture stream closed');
89
+ this.maybeRestart();
90
+ });
91
+ this.socket = socket;
92
+ }
93
+ /** If capture died while subscribers still want it, relaunch after a short backoff. */
94
+ maybeRestart() {
95
+ if (!this.active || this.restarting)
96
+ return;
97
+ this.restarting = true;
98
+ this.teardownTransport();
99
+ setTimeout(() => {
100
+ this.restarting = false;
101
+ if (!this.active)
102
+ return;
103
+ this.dlog('restarting capture backend');
104
+ this.spawnAndCapture().catch((err) => this.dlog(`capture restart failed: ${err.message}`));
105
+ }, RESTART_BACKOFF_MS).unref?.();
106
+ }
107
+ teardownTransport() {
108
+ this.socket?.destroy();
109
+ this.socket = null;
110
+ this.parser?.reset();
111
+ this.parser = null;
112
+ }
113
+ async stop() {
114
+ if (!this.active)
115
+ return;
116
+ this.active = false;
117
+ try {
118
+ if (this.proc)
119
+ await this.send({ cmd: 'stop_capture' }).catch(() => { });
120
+ }
121
+ finally {
122
+ this.teardownTransport();
123
+ this.rl?.close();
124
+ this.rl = null;
125
+ this.proc?.kill();
126
+ this.proc = null;
127
+ this.hub.clear();
128
+ this.dlog('capture stopped');
129
+ }
130
+ }
131
+ send(req) {
132
+ return new Promise((resolve) => {
133
+ if (!this.proc || !this.proc.stdin?.writable) {
134
+ resolve({ ok: false, error: 'capture process not running' });
135
+ return;
136
+ }
137
+ this.pending.push(resolve);
138
+ this.proc.stdin.write(JSON.stringify(req) + '\n');
139
+ });
140
+ }
141
+ }
142
+ exports.IOSCaptureSource = IOSCaptureSource;