appium-uiautomator2-driver 8.1.2 → 8.2.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,303 @@
1
+ import {
2
+ Transform,
3
+ Writable,
4
+ type Readable,
5
+ type TransformCallback,
6
+ type WritableOptions,
7
+ } from 'node:stream';
8
+
9
+ import {logger} from 'appium/support.js';
10
+ import axios from 'axios';
11
+ import type sharp from 'sharp';
12
+
13
+ const log = logger.getLogger('MJPEG');
14
+
15
+ const DEFAULT_SERVER_TIMEOUT_MS = 10000;
16
+ const JPEG_SOI = Buffer.from([0xff, 0xd8]);
17
+ const JPEG_EOI = Buffer.from([0xff, 0xd9]);
18
+ const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i;
19
+
20
+ /**
21
+ * Extracts individual JPEG frames out of a multipart MJPEG-over-HTTP byte stream by
22
+ * scanning for JPEG start/end-of-image markers and the multipart `Content-Length` header.
23
+ */
24
+ export class MjpegFrameParser extends Transform {
25
+ private buffer: Buffer | null = null;
26
+ private expectedLength = 0;
27
+ private bytesWritten = 0;
28
+ private isReading = false;
29
+
30
+ /* eslint-disable promise/prefer-await-to-callbacks -- Transform._transform is callback-based */
31
+ override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
32
+ const startIdx = chunk.indexOf(JPEG_SOI);
33
+ const endIdx = chunk.indexOf(JPEG_EOI);
34
+ const lengthMatch = CONTENT_LENGTH_RE.exec(chunk.toString('latin1'));
35
+
36
+ if (this.buffer && (this.isReading || startIdx > -1)) {
37
+ this.appendChunk(chunk, endIdx);
38
+ }
39
+ if (lengthMatch) {
40
+ this.startFrame(Number(lengthMatch[1]), chunk, startIdx, endIdx);
41
+ }
42
+ callback();
43
+ }
44
+ /* eslint-enable promise/prefer-await-to-callbacks */
45
+
46
+ private startFrame(length: number, chunk: Buffer, start: number, end: number): void {
47
+ this.expectedLength = length;
48
+ this.buffer = Buffer.alloc(length);
49
+ this.bytesWritten = 0;
50
+ this.isReading = false;
51
+
52
+ if (start < 0) {
53
+ return;
54
+ }
55
+ const hasEnd = end > start;
56
+ const copyEnd = hasEnd ? end + JPEG_EOI.length : chunk.length;
57
+ // Buffer.copy() silently truncates if the destination has less room than requested,
58
+ // so bytesWritten must track what was actually copied, not the requested range size.
59
+ this.bytesWritten = chunk.copy(this.buffer, 0, start, copyEnd);
60
+
61
+ if (hasEnd) {
62
+ this.emitFrame();
63
+ } else {
64
+ this.isReading = true;
65
+ }
66
+ }
67
+
68
+ private appendChunk(chunk: Buffer, end: number): void {
69
+ if (!this.buffer) {
70
+ return;
71
+ }
72
+ // We are continuing a frame that is already open, so the whole chunk belongs to it
73
+ // and must be copied starting at offset 0. A JPEG SOI marker found anywhere in this
74
+ // chunk can only belong to the *next* frame (it necessarily comes after our own EOI),
75
+ // so it must never be used as the copy start here, or the current frame's tail bytes
76
+ // between offset 0 and that marker would be skipped, truncating it.
77
+ const copyEnd = end > -1 ? end + JPEG_EOI.length : chunk.length;
78
+ // Buffer.copy() silently truncates if the destination has less room than requested,
79
+ // so bytesWritten must track what was actually copied, not the requested range size.
80
+ this.bytesWritten += chunk.copy(this.buffer, this.bytesWritten, 0, copyEnd);
81
+
82
+ if (end > -1 || this.bytesWritten === this.expectedLength) {
83
+ this.emitFrame();
84
+ } else {
85
+ this.isReading = true;
86
+ }
87
+ }
88
+
89
+ private emitFrame(): void {
90
+ this.isReading = false;
91
+ if (this.buffer) {
92
+ // Only push what was actually copied: the buffer is allocated to `expectedLength`
93
+ // up front, so pushing it as-is would leak trailing zero bytes whenever fewer
94
+ // bytes were actually written (e.g. a mismatched/truncated Content-Length).
95
+ this.push(this.buffer.subarray(0, this.bytesWritten));
96
+ }
97
+ // Clear the frame state so an unrelated later chunk (e.g. one with a JPEG SOI
98
+ // marker but no Content-Length header) cannot be appended onto a buffer that
99
+ // has already been pushed downstream.
100
+ this.buffer = null;
101
+ this.expectedLength = 0;
102
+ this.bytesWritten = 0;
103
+ }
104
+ }
105
+
106
+ let sharpModule: typeof sharp | null = null;
107
+
108
+ async function requireSharp(): Promise<typeof sharp> {
109
+ if (sharpModule) {
110
+ return sharpModule;
111
+ }
112
+ try {
113
+ sharpModule = (await import('sharp')).default;
114
+ return sharpModule;
115
+ } catch (err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ throw new Error(
118
+ `Cannot load the 'sharp' module needed for MJPEG frame processing. ` +
119
+ `Consider visiting https://sharp.pixelplumbing.com/install for troubleshooting. ` +
120
+ `Original error: ${message}`,
121
+ {cause: err},
122
+ );
123
+ }
124
+ }
125
+
126
+ const noop = () => {};
127
+
128
+ /**
129
+ * Connects to an MJPEG-over-HTTP stream and keeps track of the last JPEG frame received,
130
+ * so that it can be used as a cheap, low-latency screenshot source.
131
+ */
132
+ export class MJpegStream extends Writable {
133
+ readonly errorHandler: (err: Error) => void;
134
+ readonly url: string;
135
+ private updateCount = 0;
136
+ private lastChunk: Buffer | null = null;
137
+ private registerStartSuccess: (() => void) | null = null;
138
+ private registerStartFailure: ((err: Error) => void) | null = null;
139
+ private responseStream: Readable | null = null;
140
+ private consumer: MjpegFrameParser | null = null;
141
+
142
+ /**
143
+ * @param mJpegUrl - URL of the MJPEG-over-HTTP stream
144
+ * @param errorHandler - additional function that will be called in the case of any errors
145
+ * @param options - Options to pass to the Writable constructor
146
+ */
147
+ constructor(
148
+ mJpegUrl: string,
149
+ errorHandler: (err: Error) => void = noop,
150
+ options: WritableOptions = {},
151
+ ) {
152
+ super(options);
153
+ this.errorHandler = errorHandler;
154
+ this.url = mJpegUrl;
155
+ this.clear();
156
+ }
157
+
158
+ get lastChunkBase64(): string | null {
159
+ const lastChunk = this.lastChunk;
160
+ return lastChunk && lastChunk.length > 0 ? lastChunk.toString('base64') : null;
161
+ }
162
+
163
+ async lastChunkPNG(): Promise<Buffer | null> {
164
+ const chunk = this.lastChunk;
165
+ if (!chunk || chunk.length === 0) {
166
+ return null;
167
+ }
168
+ try {
169
+ const sharp = await requireSharp();
170
+ return await sharp(chunk).png().toBuffer();
171
+ } catch (err: any) {
172
+ log.warn(`Cannot convert MJPEG chunk to PNG: ${err.message}`);
173
+ return null;
174
+ }
175
+ }
176
+
177
+ async lastChunkPNGBase64(): Promise<string | null> {
178
+ const png = await this.lastChunkPNG();
179
+ return png ? png.toString('base64') : null;
180
+ }
181
+
182
+ clear(): void {
183
+ this.registerStartSuccess = null;
184
+ this.registerStartFailure = null;
185
+ this.responseStream = null;
186
+ this.consumer = null;
187
+ this.lastChunk = null;
188
+ this.updateCount = 0;
189
+ }
190
+
191
+ async start(serverTimeout = DEFAULT_SERVER_TIMEOUT_MS): Promise<void> {
192
+ this.stop();
193
+
194
+ this.consumer = new MjpegFrameParser();
195
+ const url = this.url;
196
+ // Bound only the connect phase with an abort signal; axios's own `timeout` option would
197
+ // otherwise keep ticking for the whole request lifetime and race with the "first frame"
198
+ // watchdog below, since both would share the same deadline.
199
+ const connectController = new AbortController();
200
+ const connectTimeoutId = setTimeout(() => connectController.abort(), serverTimeout);
201
+ try {
202
+ try {
203
+ this.responseStream = (
204
+ await axios({
205
+ url,
206
+ responseType: 'stream',
207
+ signal: connectController.signal,
208
+ })
209
+ ).data as Readable;
210
+ } catch (e) {
211
+ let message: string;
212
+ if (e && typeof e === 'object' && 'response' in e) {
213
+ message = JSON.stringify((e as {response: unknown}).response);
214
+ } else if (e instanceof Error) {
215
+ message = e.message;
216
+ } else {
217
+ message = String(e);
218
+ }
219
+ throw new Error(
220
+ `Cannot connect to the MJPEG stream at ${url}. Original error: ${message}`,
221
+ {
222
+ cause: e,
223
+ },
224
+ );
225
+ }
226
+ } finally {
227
+ clearTimeout(connectTimeoutId);
228
+ }
229
+
230
+ const onErr = (err: Error) => {
231
+ this.lastChunk = null;
232
+ log.error(`Error getting MJPEG screenshot chunk: ${err.message}`);
233
+ this.errorHandler(err);
234
+ this.registerStartFailure?.(err);
235
+ };
236
+ const onClose = () => {
237
+ log.debug(`The connection to MJPEG server at ${url} has been closed`);
238
+ this.lastChunk = null;
239
+ // No-op if start() has already resolved; only rejects a still-pending start().
240
+ this.registerStartFailure?.(
241
+ new Error(
242
+ `The connection to the MJPEG stream at ${url} has been closed before any frame was received`,
243
+ ),
244
+ );
245
+ };
246
+
247
+ let timeoutId: NodeJS.Timeout | undefined;
248
+ const startPromise = new Promise<void>((resolve, reject) => {
249
+ this.registerStartSuccess = resolve;
250
+ this.registerStartFailure = reject;
251
+ timeoutId = setTimeout(
252
+ () =>
253
+ reject(new Error(`Waited ${serverTimeout}ms but the MJPEG server never sent any images`)),
254
+ serverTimeout,
255
+ );
256
+ });
257
+
258
+ (this.responseStream as Readable & {pipe<T extends Writable>(dest: T): T})
259
+ .once('close', onClose)
260
+ .on('error', onErr)
261
+ .pipe(this.consumer)
262
+ .pipe(this);
263
+
264
+ try {
265
+ await startPromise;
266
+ } catch (err) {
267
+ // Do not leak the underlying HTTP connection/pipes if we never reached a usable state.
268
+ this.stop();
269
+ throw err;
270
+ } finally {
271
+ clearTimeout(timeoutId);
272
+ }
273
+ }
274
+
275
+ stop(): void {
276
+ if (this.consumer) {
277
+ this.consumer.unpipe(this);
278
+ }
279
+ if (this.responseStream) {
280
+ if (this.consumer) {
281
+ this.responseStream.unpipe(this.consumer);
282
+ }
283
+ this.responseStream.destroy();
284
+ }
285
+ this.clear();
286
+ }
287
+
288
+ /* eslint-disable promise/prefer-await-to-callbacks -- Writable._write is callback-based */
289
+ override _write(
290
+ chunk: Buffer | string,
291
+ _encoding: BufferEncoding,
292
+ callback: (error?: Error | null) => void,
293
+ ): void {
294
+ this.lastChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
295
+ this.updateCount++;
296
+ if (this.registerStartSuccess) {
297
+ this.registerStartSuccess();
298
+ this.registerStartSuccess = null;
299
+ }
300
+ callback();
301
+ }
302
+ /* eslint-enable promise/prefer-await-to-callbacks */
303
+ }