dsh-mobilecode 0.2.0 → 0.3.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,274 @@
1
+ /**
2
+ * dsh-mobilecode — in-process MJPEG-style frame pipeline for one Android device.
3
+ *
4
+ * Ported from ZSeven-W/dsh-android (frame-source.ts, MIT). No external stream
5
+ * helper and no inner loopback port: ONE persistent `adb exec-out` child runs a
6
+ * `screencap -p` loop on the device, this module splits the concatenated PNG
7
+ * output into frames, and the web routes serve the latest frame straight from
8
+ * memory as a `multipart/x-mixed-replace` body (PNG parts — Chromium and
9
+ * Firefox render those exactly like JPEG parts).
10
+ *
11
+ * The persistent child is the heart of the design: spawning adb per frame costs
12
+ * ~200 ms per screenshot (~5 fps ceiling), while one `while :; do screencap -p;
13
+ * done` child streams ~8 fps on an emulator with zero per-frame process cost.
14
+ * The child is intentionally dumb — it exits, this module reports it, and the
15
+ * host controller (android-stream.js) owns restart / keep-alive policy.
16
+ */
17
+
18
+ import * as DeviceBuild from './device-build.js'
19
+
20
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
21
+ const IEND_TYPE = 0x49454e44 // 'IEND'
22
+ /** A screencap frame larger than this means we lost sync; rescan. */
23
+ const MAX_FRAME_BYTES = 64 * 1024 * 1024
24
+ /** Bytes kept while hunting for a signature in garbage (stderr noise, …). */
25
+ const MAX_UNSYNCED_BYTES = 1024 * 1024
26
+ const STDERR_RING_LINES = 20
27
+ const STDERR_LINE_MAX_CHARS = 240
28
+ export const STREAM_BOUNDARY = 'dsh-mobilecode-frame'
29
+
30
+ /** Pixel size of a PNG from its IHDR chunk, without decoding the image. */
31
+ export function pngDimensions(buffer) {
32
+ if (buffer.length < 24) return undefined
33
+ if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return undefined
34
+ if (buffer.readUInt32BE(12) !== 0x49484452) return undefined // 'IHDR'
35
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
36
+ }
37
+
38
+ /**
39
+ * Incremental splitter over a byte stream of back-to-back PNG images.
40
+ *
41
+ * PNG framing is self-describing (8-byte signature, then length-prefixed chunks
42
+ * until IEND), so frames are cut by walking chunk headers — no scanning of image
43
+ * data for markers, no false positives. When the stream derails the splitter
44
+ * drops bytes until the next signature instead of stalling.
45
+ */
46
+ export class PngFrameSplitter {
47
+ #buffer = Buffer.alloc(0)
48
+
49
+ /** Feed bytes; returns every complete PNG that ended inside them. */
50
+ push(chunk) {
51
+ this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk])
52
+ const frames = []
53
+ for (;;) {
54
+ const start = this.#buffer.indexOf(PNG_SIGNATURE)
55
+ if (start < 0) {
56
+ if (this.#buffer.length > MAX_UNSYNCED_BYTES) {
57
+ this.#buffer = this.#buffer.subarray(this.#buffer.length - PNG_SIGNATURE.length)
58
+ }
59
+ return frames
60
+ }
61
+ if (start > 0) this.#buffer = this.#buffer.subarray(start)
62
+ const end = this.#frameEnd()
63
+ if (end === undefined) {
64
+ if (this.#buffer.length > MAX_FRAME_BYTES) {
65
+ this.#buffer = this.#buffer.subarray(1)
66
+ continue
67
+ }
68
+ return frames
69
+ }
70
+ frames.push(this.#buffer.subarray(0, end))
71
+ this.#buffer = this.#buffer.subarray(end)
72
+ }
73
+ }
74
+
75
+ /** Byte length of the complete PNG at the buffer start, if fully buffered. */
76
+ #frameEnd() {
77
+ let offset = PNG_SIGNATURE.length
78
+ for (;;) {
79
+ if (offset + 8 > this.#buffer.length) return undefined
80
+ const dataLength = this.#buffer.readUInt32BE(offset)
81
+ const type = this.#buffer.readUInt32BE(offset + 4)
82
+ if (dataLength > MAX_FRAME_BYTES) return undefined // corrupt header; caller rescans
83
+ const next = offset + 8 + dataLength + 4
84
+ if (next > this.#buffer.length) return undefined
85
+ if (type === IEND_TYPE) return next
86
+ offset = next
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Owns the one persistent screencap child for one device serial and the
93
+ * latest-frame buffer every consumer reads from.
94
+ */
95
+ export class AdbFrameLoop {
96
+ #child
97
+ #splitter = new PngFrameSplitter()
98
+ #latest
99
+ #sequence = 0
100
+ #stderrRing = []
101
+ #stderrPartial = ''
102
+ #frameWaiters = []
103
+ #stopped = false
104
+
105
+ constructor(serial, events = {}) {
106
+ this.serial = serial
107
+ this.events = events
108
+ }
109
+
110
+ get running() {
111
+ const child = this.#child
112
+ return child !== undefined && child.exitCode === null && child.signalCode === null
113
+ }
114
+
115
+ get latestFrame() {
116
+ return this.#latest
117
+ }
118
+
119
+ get stderrLines() {
120
+ return [...this.#stderrRing]
121
+ }
122
+
123
+ /** Spawn the screencap loop child (idempotent while running). */
124
+ start() {
125
+ if (this.running || this.#stopped) return
126
+ // `exec-out` skips the pty (binary-safe); the single-string command runs
127
+ // through the *device* shell, so one child produces frames forever.
128
+ const child = DeviceBuild.launch(
129
+ DeviceBuild.adb(),
130
+ ['-s', this.serial, 'exec-out', 'while :; do screencap -p; done'],
131
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
132
+ )
133
+ this.#child = child
134
+ child.stdout?.on('data', (chunk) => {
135
+ for (const png of this.#splitter.push(chunk)) this.#acceptFrame(png)
136
+ })
137
+ child.stderr?.on('data', (chunk) => this.#recordStderr(chunk))
138
+ child.once('error', (error) => {
139
+ this.#recordStderr(Buffer.from(`spawn error: ${error.message}\n`))
140
+ })
141
+ child.once('close', (code, signal) => {
142
+ if (this.#child !== child) return
143
+ this.#child = undefined
144
+ const detail = signal !== null ? `killed by ${signal}` : `exit ${String(code)}`
145
+ if (!this.#stopped) this.events.onExit?.(detail)
146
+ })
147
+ }
148
+
149
+ /** Kill the child; the loop object can be started again later. */
150
+ stop() {
151
+ this.#stopped = true
152
+ const child = this.#child
153
+ this.#child = undefined
154
+ if (child !== undefined && child.exitCode === null && child.signalCode === null) {
155
+ child.kill('SIGTERM')
156
+ const hardKill = setTimeout(() => {
157
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
158
+ }, 2000)
159
+ hardKill.unref?.()
160
+ }
161
+ const waiters = this.#frameWaiters
162
+ this.#frameWaiters = []
163
+ if (this.#latest !== undefined) for (const waiter of waiters) waiter(this.#latest)
164
+ }
165
+
166
+ /** Allow a stopped loop to be started again (host restart path). */
167
+ reset() {
168
+ this.#stopped = false
169
+ this.#splitter = new PngFrameSplitter()
170
+ }
171
+
172
+ /** The next frame (or the latest one already buffered), bounded in time. */
173
+ waitForFrame(timeoutMs) {
174
+ const latest = this.#latest
175
+ if (latest !== undefined) return Promise.resolve(latest)
176
+ return new Promise((resolve) => {
177
+ let settled = false
178
+ const waiter = (frame) => {
179
+ if (settled) return
180
+ settled = true
181
+ clearTimeout(timer)
182
+ resolve(frame)
183
+ }
184
+ const timer = setTimeout(() => {
185
+ if (settled) return
186
+ settled = true
187
+ const index = this.#frameWaiters.indexOf(waiter)
188
+ if (index >= 0) this.#frameWaiters.splice(index, 1)
189
+ resolve(undefined)
190
+ }, timeoutMs)
191
+ timer.unref?.()
192
+ this.#frameWaiters.push(waiter)
193
+ })
194
+ }
195
+
196
+ #acceptFrame(png) {
197
+ const size = pngDimensions(png)
198
+ if (size === undefined) return
199
+ this.#sequence += 1
200
+ const frame = { png, width: size.width, height: size.height, sequence: this.#sequence, at: Date.now() }
201
+ this.#latest = frame
202
+ const waiters = this.#frameWaiters
203
+ this.#frameWaiters = []
204
+ for (const waiter of waiters) waiter(frame)
205
+ this.events.onFrame?.(frame)
206
+ }
207
+
208
+ #recordStderr(chunk) {
209
+ const text = this.#stderrPartial + chunk.toString('utf8')
210
+ const lines = text.split('\n')
211
+ this.#stderrPartial = lines.pop() ?? ''
212
+ for (const line of lines) {
213
+ const trimmed = line.trimEnd()
214
+ if (trimmed === '') continue
215
+ this.#stderrRing.push(trimmed.length > STDERR_LINE_MAX_CHARS ? `${trimmed.slice(0, STDERR_LINE_MAX_CHARS)}…` : trimmed)
216
+ if (this.#stderrRing.length > STDERR_RING_LINES) this.#stderrRing.shift()
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write one live multipart/x-mixed-replace response from a frame feed.
223
+ * Backpressure is latest-wins: when the client socket is saturated the writer
224
+ * skips frames instead of queueing them, so a slow tab never builds an
225
+ * unbounded buffer or watches a growing delay.
226
+ */
227
+ export class MultipartFrameWriter {
228
+ #closed = false
229
+ #congested = false
230
+
231
+ constructor(res) {
232
+ this.res = res
233
+ res.writeHead(200, {
234
+ 'content-type': `multipart/x-mixed-replace; boundary=${STREAM_BOUNDARY}`,
235
+ 'cache-control': 'no-cache, no-store',
236
+ 'x-content-type-options': 'nosniff',
237
+ 'cross-origin-resource-policy': 'same-origin',
238
+ 'referrer-policy': 'no-referrer',
239
+ })
240
+ res.on('drain', () => {
241
+ this.#congested = false
242
+ })
243
+ }
244
+
245
+ get closed() {
246
+ return this.#closed
247
+ }
248
+
249
+ /** Write one frame part; silently skipped while the socket is congested. */
250
+ writeFrame(frame) {
251
+ if (this.#closed || this.#congested) return
252
+ const header = `--${STREAM_BOUNDARY}\r\n`
253
+ + 'Content-Type: image/png\r\n'
254
+ + `Content-Length: ${frame.png.length}\r\n\r\n`
255
+ try {
256
+ this.res.write(header)
257
+ const flushed = this.res.write(frame.png)
258
+ this.res.write('\r\n')
259
+ if (!flushed) this.#congested = true
260
+ } catch {
261
+ this.close()
262
+ }
263
+ }
264
+
265
+ close() {
266
+ if (this.#closed) return
267
+ this.#closed = true
268
+ try {
269
+ this.res.end()
270
+ } catch {
271
+ // The socket may already be gone.
272
+ }
273
+ }
274
+ }