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.
- package/README.md +43 -0
- package/lib/android-stream.js +378 -0
- package/lib/client.js +133 -0
- package/lib/device-build.js +51 -0
- package/lib/frame-source.js +274 -0
- package/lib/index.js +549 -7
- package/lib/stream-access.js +220 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|