@autifyhq/aximo-cli 0.9.0 → 0.10.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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/device/connect.js +19 -1
  3. package/dist/commands/device/doctor.d.ts +2 -0
  4. package/dist/commands/device/doctor.js +37 -10
  5. package/dist/commands/device/doctor.test.js +26 -1
  6. package/dist/commands/mcp/server.test.js +3 -1
  7. package/dist/commands/mcp/tools.js +28 -0
  8. package/dist/commands/mcp/tools.test.js +90 -0
  9. package/dist/commands/mobile/connect-core.d.ts +2 -2
  10. package/dist/commands/mobile/connect-core.js +35 -2
  11. package/dist/commands/mobile/signing-consent.js +5 -11
  12. package/dist/desktop-server/preflight.d.ts +43 -0
  13. package/dist/desktop-server/preflight.js +78 -0
  14. package/dist/desktop-server/preflight.test.d.ts +1 -0
  15. package/dist/desktop-server/preflight.test.js +115 -0
  16. package/dist/mobile-control/appium-controller.d.ts +24 -0
  17. package/dist/mobile-control/appium-controller.js +218 -127
  18. package/dist/mobile-control/appium-controller.test.js +102 -0
  19. package/dist/mobile-control/frame-sender.d.ts +34 -0
  20. package/dist/mobile-control/frame-sender.js +96 -0
  21. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  22. package/dist/mobile-control/frame-sender.test.js +108 -0
  23. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  24. package/dist/mobile-control/mjpeg-stream.js +160 -0
  25. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  26. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  27. package/dist/mobile-control/tunnel.d.ts +10 -1
  28. package/dist/mobile-control/tunnel.js +20 -2
  29. package/dist/mobile-control/tunnel.test.js +33 -0
  30. package/dist/mobile-control/webdriver.d.ts +2 -0
  31. package/dist/mobile-control/webdriver.js +6 -0
  32. package/dist/prompt.d.ts +11 -0
  33. package/dist/prompt.js +19 -0
  34. package/dist/types.d.ts +17 -0
  35. package/package.json +1 -1
@@ -398,3 +398,105 @@ describe("AppiumController launch + lifecycle", () => {
398
398
  expect(calls.some((c) => c.method === "DELETE" && c.path === "/session/sess-1")).toBe(true);
399
399
  });
400
400
  });
401
+ describe("AppiumController MJPEG capture", () => {
402
+ it("adds the mjpegServerPort capability and applies conservative settings", async () => {
403
+ const { transport, calls } = fakeTransport();
404
+ const controller = new AppiumController({
405
+ endpoint: "http://x",
406
+ platform: "android",
407
+ target: "udid-1",
408
+ mjpegServerPort: async () => 7777,
409
+ transport,
410
+ });
411
+ await controller.ensureReady();
412
+ const body = calls.find((c) => c.method === "POST" && c.path === "/session")?.body;
413
+ expect(body["capabilities"]?.alwaysMatch["appium:mjpegServerPort"]).toBe(7777);
414
+ const settings = calls.find((c) => c.path.endsWith("/appium/settings"));
415
+ expect(settings?.body).toEqual({
416
+ settings: {
417
+ mjpegServerFramerate: 10,
418
+ mjpegScalingFactor: 50,
419
+ mjpegServerScreenshotQuality: 40,
420
+ },
421
+ });
422
+ expect(controller.getMjpegPort()).toBe(7777);
423
+ });
424
+ it("omits the capability and settings call when no port is configured", async () => {
425
+ const { controller, calls } = makeController("android");
426
+ await controller.screenshot();
427
+ const body = calls.find((c) => c.method === "POST" && c.path === "/session")?.body;
428
+ expect(body["capabilities"]?.alwaysMatch["appium:mjpegServerPort"]).toBeUndefined();
429
+ expect(calls.some((c) => c.path.endsWith("/appium/settings"))).toBe(false);
430
+ expect(controller.getMjpegPort()).toBeUndefined();
431
+ });
432
+ });
433
+ describe("AppiumController session recovery", () => {
434
+ function deadSessionTransport(failuresBeforeRecovery) {
435
+ const calls = [];
436
+ let sessions = 0;
437
+ let remainingFailures = failuresBeforeRecovery;
438
+ const transport = {
439
+ async request(method, path, body) {
440
+ calls.push({ method, path, body });
441
+ if (method === "POST" && path === "/session") {
442
+ sessions += 1;
443
+ return { sessionId: `sess-${sessions}` };
444
+ }
445
+ if (path.endsWith("/screenshot")) {
446
+ if (remainingFailures > 0) {
447
+ remainingFailures -= 1;
448
+ throw new Error("WebDriver GET /screenshot failed: invalid session id");
449
+ }
450
+ return pngWithWidth(400);
451
+ }
452
+ return {};
453
+ },
454
+ };
455
+ return { transport, calls };
456
+ }
457
+ it("rebuilds the session once and retries when the cached session has died", async () => {
458
+ const { transport, calls } = deadSessionTransport(1);
459
+ const controller = new AppiumController({
460
+ endpoint: "http://x",
461
+ platform: "android",
462
+ target: "udid-1",
463
+ transport,
464
+ });
465
+ const shot = await controller.screenshot();
466
+ expect(Buffer.isBuffer(shot)).toBe(true);
467
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(2);
468
+ });
469
+ it("surfaces the error when the rebuilt session is dead too", async () => {
470
+ const { transport, calls } = deadSessionTransport(2);
471
+ const controller = new AppiumController({
472
+ endpoint: "http://x",
473
+ platform: "android",
474
+ target: "udid-1",
475
+ transport,
476
+ });
477
+ await expect(controller.screenshot()).rejects.toThrow(/invalid session id/);
478
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(2);
479
+ });
480
+ it("does not rebuild the session for ordinary command failures", async () => {
481
+ const calls = [];
482
+ const transport = {
483
+ async request(method, path, body) {
484
+ calls.push({ method, path, body });
485
+ if (method === "POST" && path === "/session")
486
+ return { sessionId: "sess-1" };
487
+ if (path.endsWith("/screenshot")) {
488
+ throw new Error("WebDriver GET /screenshot failed: unexpected server error");
489
+ }
490
+ return {};
491
+ },
492
+ };
493
+ const controller = new AppiumController({
494
+ endpoint: "http://x",
495
+ platform: "android",
496
+ target: "udid-1",
497
+ transport,
498
+ });
499
+ await expect(controller.screenshot()).rejects.toThrow(/unexpected server error/);
500
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(1);
501
+ });
502
+ });
@@ -0,0 +1,34 @@
1
+ import { type Socket } from "socket.io-client";
2
+ export interface FrameSenderOptions {
3
+ wsUrl: string;
4
+ deviceToken: string;
5
+ /** Called when the gateway acks that no viewer is watching — capture should stop. */
6
+ onInactive: () => void;
7
+ log?: (message: string) => void;
8
+ /** Test seam: overrides the socket factory. */
9
+ connect?: (wsUrl: string, deviceToken: string) => Socket;
10
+ }
11
+ /**
12
+ * Pushes JPEG frames to the gateway over a DEDICATED socket.io connection
13
+ * (handshake `role: "frames"`) so frame bytes never share a TCP stream with
14
+ * command acks or engine.io pings — on a saturated uplink that head-of-line
15
+ * blocking delays tap acks by seconds and starves pings into a tunnel drop.
16
+ *
17
+ * Sending is ack-clocked with keep-latest replacement: one frame in flight,
18
+ * and whatever arrives while it is unacknowledged replaces the queued one.
19
+ * socket.io `volatile` cannot bound this — the websocket transport reports
20
+ * writable again on the next tick without consulting the real send buffer, so
21
+ * volatile frames still queue unboundedly on a slow link.
22
+ */
23
+ export declare class FrameSender {
24
+ private readonly options;
25
+ private socket;
26
+ private pending;
27
+ private pumping;
28
+ private oversizedWarned;
29
+ constructor(options: FrameSenderOptions);
30
+ start(): void;
31
+ stop(): void;
32
+ push(frame: Buffer): void;
33
+ private pump;
34
+ }
@@ -0,0 +1,96 @@
1
+ import { io } from "socket.io-client";
2
+ const SOCKET_PATH = "/devices/tunnel";
3
+ const ACK_TIMEOUT_MS = 10_000;
4
+ // Frames at the pinned capture settings are ~30-80KB. Anything this large
5
+ // means misapplied settings; sending it would monopolize a slow uplink.
6
+ const MAX_FRAME_BYTES = 1024 * 1024;
7
+ /**
8
+ * Pushes JPEG frames to the gateway over a DEDICATED socket.io connection
9
+ * (handshake `role: "frames"`) so frame bytes never share a TCP stream with
10
+ * command acks or engine.io pings — on a saturated uplink that head-of-line
11
+ * blocking delays tap acks by seconds and starves pings into a tunnel drop.
12
+ *
13
+ * Sending is ack-clocked with keep-latest replacement: one frame in flight,
14
+ * and whatever arrives while it is unacknowledged replaces the queued one.
15
+ * socket.io `volatile` cannot bound this — the websocket transport reports
16
+ * writable again on the next tick without consulting the real send buffer, so
17
+ * volatile frames still queue unboundedly on a slow link.
18
+ */
19
+ export class FrameSender {
20
+ options;
21
+ socket = null;
22
+ pending = null;
23
+ pumping = false;
24
+ oversizedWarned = false;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ start() {
29
+ if (this.socket)
30
+ return;
31
+ const connect = this.options.connect ?? defaultConnect;
32
+ this.socket = connect(this.options.wsUrl, this.options.deviceToken);
33
+ this.socket.on("connect_error", (err) => {
34
+ this.options.log?.(`Frame connection error: ${err.message}`);
35
+ });
36
+ // A reconnect may have frames waiting from before the drop.
37
+ this.socket.on("connect", () => void this.pump());
38
+ }
39
+ stop() {
40
+ this.pending = null;
41
+ this.socket?.close();
42
+ this.socket = null;
43
+ }
44
+ push(frame) {
45
+ if (frame.length > MAX_FRAME_BYTES) {
46
+ if (!this.oversizedWarned) {
47
+ this.oversizedWarned = true;
48
+ this.options.log?.(`Dropping oversized MJPEG frame (${frame.length} bytes) — capture settings were likely not applied.`);
49
+ }
50
+ return;
51
+ }
52
+ this.pending = frame;
53
+ void this.pump();
54
+ }
55
+ async pump() {
56
+ if (this.pumping)
57
+ return;
58
+ this.pumping = true;
59
+ try {
60
+ while (this.pending) {
61
+ const socket = this.socket;
62
+ // Disconnected: leave the newest frame queued; the connect handler
63
+ // resumes the pump once the transport is back.
64
+ if (!socket?.connected)
65
+ return;
66
+ const frame = this.pending;
67
+ this.pending = null;
68
+ try {
69
+ const ack = (await socket.timeout(ACK_TIMEOUT_MS).emitWithAck("stream_frame", frame));
70
+ if (ack?.active === false) {
71
+ this.pending = null;
72
+ this.options.onInactive();
73
+ return;
74
+ }
75
+ }
76
+ catch {
77
+ // Ack timeout: the link is stalling. The frame is already stale —
78
+ // drop it and let the next capture retry.
79
+ return;
80
+ }
81
+ }
82
+ }
83
+ finally {
84
+ this.pumping = false;
85
+ }
86
+ }
87
+ }
88
+ function defaultConnect(wsUrl, deviceToken) {
89
+ return io(wsUrl, {
90
+ path: SOCKET_PATH,
91
+ transports: ["websocket"],
92
+ auth: { deviceToken, role: "frames" },
93
+ reconnectionAttempts: Infinity,
94
+ timeout: 10_000,
95
+ });
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { FrameSender } from "./frame-sender.js";
3
+ class FakeFramesSocket {
4
+ connected = true;
5
+ sent = [];
6
+ acks = [];
7
+ handlers = new Map();
8
+ on(event, handler) {
9
+ this.handlers.set(event, handler);
10
+ return this;
11
+ }
12
+ fire(event, ...args) {
13
+ this.handlers.get(event)?.(...args);
14
+ }
15
+ timeout(_ms) {
16
+ return {
17
+ emitWithAck: (_event, frame) => new Promise((resolve) => {
18
+ this.sent.push(frame);
19
+ this.acks.push(resolve);
20
+ }),
21
+ };
22
+ }
23
+ close() {
24
+ this.connected = false;
25
+ }
26
+ }
27
+ function tick() {
28
+ return new Promise((resolve) => setTimeout(resolve, 0));
29
+ }
30
+ function build(socket, onInactive = vi.fn()) {
31
+ const sender = new FrameSender({
32
+ wsUrl: "ws://gateway",
33
+ deviceToken: "token",
34
+ onInactive,
35
+ connect: () => socket,
36
+ });
37
+ sender.start();
38
+ return { sender, onInactive };
39
+ }
40
+ describe("FrameSender", () => {
41
+ it("ack-clocks with keep-latest: frames pushed mid-flight collapse to the newest", async () => {
42
+ const socket = new FakeFramesSocket();
43
+ const { sender } = build(socket);
44
+ sender.push(Buffer.from("f1"));
45
+ await tick();
46
+ sender.push(Buffer.from("f2"));
47
+ sender.push(Buffer.from("f3"));
48
+ await tick();
49
+ // f1 unacked: nothing else may be in flight.
50
+ expect(socket.sent.map(String)).toEqual(["f1"]);
51
+ socket.acks[0]?.({ active: true });
52
+ await tick();
53
+ // f2 was replaced by f3 while waiting.
54
+ expect(socket.sent.map(String)).toEqual(["f1", "f3"]);
55
+ socket.acks[1]?.({ active: true });
56
+ await tick();
57
+ expect(socket.sent).toHaveLength(2);
58
+ });
59
+ it("stops capture when the gateway acks that nobody is watching", async () => {
60
+ const socket = new FakeFramesSocket();
61
+ const { sender, onInactive } = build(socket);
62
+ sender.push(Buffer.from("frame"));
63
+ await tick();
64
+ socket.acks[0]?.({ active: false });
65
+ await tick();
66
+ expect(onInactive).toHaveBeenCalledTimes(1);
67
+ });
68
+ it("holds the newest frame while disconnected and resumes on connect", async () => {
69
+ const socket = new FakeFramesSocket();
70
+ socket.connected = false;
71
+ const { sender } = build(socket);
72
+ sender.push(Buffer.from("f1"));
73
+ sender.push(Buffer.from("f2"));
74
+ await tick();
75
+ expect(socket.sent).toHaveLength(0);
76
+ socket.connected = true;
77
+ socket.fire("connect");
78
+ await tick();
79
+ expect(socket.sent.map(String)).toEqual(["f2"]);
80
+ });
81
+ it("drops oversized frames instead of sending them", async () => {
82
+ const socket = new FakeFramesSocket();
83
+ const log = vi.fn();
84
+ const sender = new FrameSender({
85
+ wsUrl: "ws://gateway",
86
+ deviceToken: "token",
87
+ onInactive: vi.fn(),
88
+ log,
89
+ connect: () => socket,
90
+ });
91
+ sender.start();
92
+ sender.push(Buffer.alloc(2 * 1024 * 1024));
93
+ await tick();
94
+ expect(socket.sent).toHaveLength(0);
95
+ expect(log).toHaveBeenCalledOnce();
96
+ });
97
+ it("stop() clears the queue and closes the socket", async () => {
98
+ const socket = new FakeFramesSocket();
99
+ const { sender } = build(socket);
100
+ sender.push(Buffer.from("f1"));
101
+ await tick();
102
+ sender.stop();
103
+ expect(socket.connected).toBe(false);
104
+ socket.acks[0]?.({ active: true });
105
+ await tick();
106
+ expect(socket.sent).toHaveLength(1);
107
+ });
108
+ });
@@ -0,0 +1,43 @@
1
+ import { type Socket as NetSocket } from "node:net";
2
+ /**
3
+ * Incremental JPEG frame extractor for an MJPEG byte stream. Scans raw bytes
4
+ * for SOI/EOI markers instead of parsing multipart headers — the WDA and
5
+ * UiAutomator2 servers frame their streams differently, and marker scanning
6
+ * handles both and resyncs after corruption (HTTP headers and part boundaries
7
+ * fall out naturally because scanning starts at the first SOI).
8
+ */
9
+ export declare class MjpegFrameParser {
10
+ private buffer;
11
+ push(chunk: Buffer): Buffer[];
12
+ }
13
+ export interface MjpegStreamClientOptions {
14
+ /** Bring the device session (and its forwarded MJPEG port) up; called before each connect. */
15
+ ensureReady: () => Promise<void>;
16
+ /** Local port the driver forwarded to the on-device MJPEG server. */
17
+ getPort: () => number | undefined;
18
+ onFrame: (jpeg: Buffer) => void;
19
+ log?: (message: string) => void;
20
+ /** Test seam: overrides the TCP connection factory. */
21
+ createConnection?: (port: number) => NetSocket;
22
+ }
23
+ /**
24
+ * Connects to the driver-forwarded MJPEG port and emits complete JPEG frames.
25
+ * Reconnects with capped backoff while started — an Appium session rebuild
26
+ * lands on a fresh port, which `getPort` reflects on the next attempt. All
27
+ * timers are cancellable so `stop()` never holds the process open.
28
+ */
29
+ export declare class MjpegStreamClient {
30
+ private readonly options;
31
+ private active;
32
+ private socket;
33
+ private attempt;
34
+ private loop;
35
+ private wake;
36
+ constructor(options: MjpegStreamClientOptions);
37
+ isActive(): boolean;
38
+ start(): void;
39
+ stop(): Promise<void>;
40
+ private run;
41
+ private streamOnce;
42
+ private sleep;
43
+ }
@@ -0,0 +1,160 @@
1
+ import { connect } from "node:net";
2
+ const SOI = Buffer.from([0xff, 0xd8]);
3
+ const EOI = Buffer.from([0xff, 0xd9]);
4
+ // A frame at the pinned capture settings is ~30-80KB; anything near this cap
5
+ // means the parser lost sync or the driver ignored the settings. Dropping the
6
+ // buffer resyncs on the next SOI marker.
7
+ const MAX_PARSER_BUFFER = 8 * 1024 * 1024;
8
+ // No bytes at all for this long means the server is wedged (locked phone with a
9
+ // dead WDA, stale forward) — drop the connection and let the retry loop rebuild.
10
+ const STREAM_IDLE_TIMEOUT_MS = 15_000;
11
+ const MAX_RECONNECT_DELAY_MS = 10_000;
12
+ /**
13
+ * Incremental JPEG frame extractor for an MJPEG byte stream. Scans raw bytes
14
+ * for SOI/EOI markers instead of parsing multipart headers — the WDA and
15
+ * UiAutomator2 servers frame their streams differently, and marker scanning
16
+ * handles both and resyncs after corruption (HTTP headers and part boundaries
17
+ * fall out naturally because scanning starts at the first SOI).
18
+ */
19
+ export class MjpegFrameParser {
20
+ buffer = Buffer.alloc(0);
21
+ push(chunk) {
22
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
23
+ const frames = [];
24
+ for (;;) {
25
+ const soi = this.buffer.indexOf(SOI);
26
+ if (soi === -1) {
27
+ // Nothing before a frame start is useful; keep one byte in case a
28
+ // marker straddles the chunk edge.
29
+ this.buffer = this.buffer.subarray(Math.max(0, this.buffer.length - 1));
30
+ break;
31
+ }
32
+ if (soi > 0)
33
+ this.buffer = this.buffer.subarray(soi);
34
+ const eoi = this.buffer.indexOf(EOI, SOI.length);
35
+ if (eoi === -1)
36
+ break;
37
+ frames.push(this.buffer.subarray(0, eoi + EOI.length));
38
+ this.buffer = this.buffer.subarray(eoi + EOI.length);
39
+ }
40
+ if (this.buffer.length > MAX_PARSER_BUFFER)
41
+ this.buffer = Buffer.alloc(0);
42
+ return frames;
43
+ }
44
+ }
45
+ /**
46
+ * Connects to the driver-forwarded MJPEG port and emits complete JPEG frames.
47
+ * Reconnects with capped backoff while started — an Appium session rebuild
48
+ * lands on a fresh port, which `getPort` reflects on the next attempt. All
49
+ * timers are cancellable so `stop()` never holds the process open.
50
+ */
51
+ export class MjpegStreamClient {
52
+ options;
53
+ active = false;
54
+ socket = null;
55
+ attempt = 0;
56
+ loop = null;
57
+ wake = null;
58
+ constructor(options) {
59
+ this.options = options;
60
+ }
61
+ isActive() {
62
+ return this.active;
63
+ }
64
+ start() {
65
+ if (this.active)
66
+ return;
67
+ this.active = true;
68
+ this.attempt = 0;
69
+ this.loop = this.run().catch((err) => {
70
+ this.options.log?.(`MJPEG capture loop crashed: ${err instanceof Error ? err.message : String(err)}`);
71
+ });
72
+ }
73
+ async stop() {
74
+ if (!this.active)
75
+ return;
76
+ this.active = false;
77
+ this.socket?.destroy();
78
+ this.socket = null;
79
+ this.wake?.();
80
+ await this.loop;
81
+ this.loop = null;
82
+ }
83
+ async run() {
84
+ while (this.active) {
85
+ try {
86
+ await this.options.ensureReady();
87
+ if (!this.active)
88
+ break;
89
+ const port = this.options.getPort();
90
+ if (!port)
91
+ throw new Error("MJPEG server port is not configured");
92
+ await this.streamOnce(port);
93
+ }
94
+ catch (err) {
95
+ if (this.active) {
96
+ this.options.log?.(`MJPEG stream interrupted: ${err instanceof Error ? err.message : String(err)}`);
97
+ }
98
+ }
99
+ if (!this.active)
100
+ break;
101
+ const delay = Math.min(1000 * 2 ** this.attempt, MAX_RECONNECT_DELAY_MS);
102
+ this.attempt += 1;
103
+ await this.sleep(delay);
104
+ }
105
+ }
106
+ streamOnce(port) {
107
+ return new Promise((resolve, reject) => {
108
+ const parser = new MjpegFrameParser();
109
+ const socket = this.options.createConnection?.(port) ?? connect({ host: "127.0.0.1", port });
110
+ this.socket = socket;
111
+ let failed = null;
112
+ socket.setNoDelay(true);
113
+ socket.setTimeout(STREAM_IDLE_TIMEOUT_MS, () => {
114
+ failed = new Error(`no stream data for ${STREAM_IDLE_TIMEOUT_MS}ms`);
115
+ socket.destroy();
116
+ });
117
+ // WDA streams to any TCP client; the UiAutomator2 server wants a request
118
+ // line first. Sending one is harmless for both.
119
+ socket.on("connect", () => {
120
+ socket.write("GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n");
121
+ });
122
+ socket.on("data", (chunk) => {
123
+ const frames = parser.push(chunk);
124
+ if (frames.length === 0)
125
+ return;
126
+ // A delivered frame proves the pipeline works — restart backoff.
127
+ this.attempt = 0;
128
+ // Keep-latest: only the newest frame matters for a live view.
129
+ const latest = frames[frames.length - 1];
130
+ if (latest)
131
+ this.options.onFrame(latest);
132
+ });
133
+ socket.on("error", (err) => {
134
+ failed = failed ?? err;
135
+ });
136
+ socket.on("close", () => {
137
+ if (this.socket === socket)
138
+ this.socket = null;
139
+ if (failed)
140
+ reject(failed);
141
+ else
142
+ resolve();
143
+ });
144
+ });
145
+ }
146
+ sleep(ms) {
147
+ return new Promise((resolve) => {
148
+ const timer = setTimeout(() => {
149
+ this.wake = null;
150
+ resolve();
151
+ }, ms);
152
+ timer.unref?.();
153
+ this.wake = () => {
154
+ clearTimeout(timer);
155
+ this.wake = null;
156
+ resolve();
157
+ };
158
+ });
159
+ }
160
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { createServer } from "node:net";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { MjpegFrameParser, MjpegStreamClient } from "./mjpeg-stream.js";
4
+ function jpeg(payload) {
5
+ return Buffer.from([0xff, 0xd8, ...payload, 0xff, 0xd9]);
6
+ }
7
+ describe("MjpegFrameParser", () => {
8
+ it("extracts a complete frame from one chunk", () => {
9
+ const parser = new MjpegFrameParser();
10
+ const frame = jpeg([1, 2, 3]);
11
+ expect(parser.push(frame)).toEqual([frame]);
12
+ });
13
+ it("reassembles a frame split across chunks", () => {
14
+ const parser = new MjpegFrameParser();
15
+ const frame = jpeg([9, 8, 7, 6]);
16
+ expect(parser.push(frame.subarray(0, 3))).toEqual([]);
17
+ expect(parser.push(frame.subarray(3))).toEqual([frame]);
18
+ });
19
+ it("skips multipart headers and boundary bytes between frames", () => {
20
+ const parser = new MjpegFrameParser();
21
+ const a = jpeg([1]);
22
+ const b = jpeg([2]);
23
+ const stream = Buffer.concat([
24
+ Buffer.from("HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace\r\n\r\n--BoundaryString\r\n"),
25
+ a,
26
+ Buffer.from("\r\n--BoundaryString\r\nContent-Length: 4\r\n\r\n"),
27
+ b,
28
+ ]);
29
+ expect(parser.push(stream)).toEqual([a, b]);
30
+ });
31
+ it("keeps a trailing partial marker across pushes", () => {
32
+ const parser = new MjpegFrameParser();
33
+ const frame = jpeg([5]);
34
+ // First push ends exactly on the 0xff of the next frame's SOI.
35
+ expect(parser.push(Buffer.concat([frame, Buffer.from([0xff])]))).toEqual([frame]);
36
+ const next = jpeg([6]);
37
+ expect(parser.push(next.subarray(1))).toEqual([next]);
38
+ });
39
+ });
40
+ describe("MjpegStreamClient", () => {
41
+ let server = null;
42
+ afterEach(() => {
43
+ server?.close();
44
+ server = null;
45
+ });
46
+ function listen(handler) {
47
+ return new Promise((resolve) => {
48
+ const created = createServer((socket) => {
49
+ socket.on("error", () => { });
50
+ handler((data) => socket.write(data));
51
+ });
52
+ server = created;
53
+ created.listen(0, "127.0.0.1", () => {
54
+ resolve(created.address().port);
55
+ });
56
+ });
57
+ }
58
+ it("delivers frames from the device stream", async () => {
59
+ const frame = jpeg([1, 2, 3, 4]);
60
+ const port = await listen((write) => {
61
+ write(Buffer.from("HTTP/1.1 200 OK\r\n\r\n"));
62
+ write(frame);
63
+ });
64
+ const frames = [];
65
+ const client = new MjpegStreamClient({
66
+ ensureReady: async () => { },
67
+ getPort: () => port,
68
+ onFrame: (f) => frames.push(f),
69
+ });
70
+ client.start();
71
+ await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1));
72
+ expect(frames[0]?.equals(frame)).toBe(true);
73
+ await client.stop();
74
+ });
75
+ it("stop() resolves promptly and stops reconnect attempts", async () => {
76
+ let readyCalls = 0;
77
+ const client = new MjpegStreamClient({
78
+ ensureReady: async () => {
79
+ readyCalls += 1;
80
+ throw new Error("session down");
81
+ },
82
+ getPort: () => undefined,
83
+ onFrame: () => { },
84
+ });
85
+ client.start();
86
+ await vi.waitFor(() => expect(readyCalls).toBeGreaterThanOrEqual(1));
87
+ await client.stop();
88
+ const after = readyCalls;
89
+ await new Promise((resolve) => setTimeout(resolve, 50));
90
+ expect(readyCalls).toBe(after);
91
+ expect(client.isActive()).toBe(false);
92
+ });
93
+ });
@@ -9,7 +9,16 @@ export interface RunControlTunnelOptions {
9
9
  installApp?: (args: {
10
10
  uploadId: string;
11
11
  }) => Promise<void>;
12
+ /**
13
+ * Live-view MJPEG pipeline hooks. The gateway demand-gates capture:
14
+ * `stream_start` when a viewer is watching, `stream_stop` when idle. Capture
15
+ * must also stop on release/disconnect so an unwatched phone never streams.
16
+ */
17
+ stream?: {
18
+ start: () => void;
19
+ stop: () => void;
20
+ };
12
21
  connect?: (wsUrl: string, deviceToken: string) => Socket;
13
22
  }
14
- export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"]): void;
23
+ export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"], stream?: RunControlTunnelOptions["stream"]): void;
15
24
  export declare function runControlTunnel(options: RunControlTunnelOptions): Promise<void>;