dsh-mobilecode 0.2.1 → 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 +28 -0
- package/lib/android-stream.js +378 -0
- package/lib/client.js +133 -0
- package/lib/frame-source.js +274 -0
- package/lib/index.js +222 -2
- package/lib/stream-access.js +220 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,34 @@ drawer:
|
|
|
87
87
|
- `device_apps` / `device_launch_app` — list installed packages (third-party by
|
|
88
88
|
default) so a package name is never guessed / launch one by package or a
|
|
89
89
|
unique substring, with `relaunch` for a cold start.
|
|
90
|
+
- `device_stream` — drive the live screen stream the panel shows: `start` an
|
|
91
|
+
online device (returns a signed `streamUrl`), `status`, or `stop`. Agents that
|
|
92
|
+
just need to see the screen should prefer `device_screen` / `device_ui_tree`.
|
|
93
|
+
|
|
94
|
+
**Live device stream (the panel)**
|
|
95
|
+
|
|
96
|
+
The Devices pane shows a real-time mirror of the attached device. It is produced
|
|
97
|
+
**in-process** — no inner loopback port, no external helper:
|
|
98
|
+
|
|
99
|
+
- ONE persistent `adb exec-out "while :; do screencap -p; done"` child streams
|
|
100
|
+
~8 fps with zero per-frame process cost (spawning adb per frame caps at ~5 fps
|
|
101
|
+
and 100% churn). A quote-safe PNG splitter cuts the concatenated output into
|
|
102
|
+
frames by walking chunk headers (no marker scanning, no false positives).
|
|
103
|
+
- The browser `<img>` reads a `multipart/x-mixed-replace` body served straight
|
|
104
|
+
from the latest-frame buffer. Backpressure is **latest-wins**: a slow tab skips
|
|
105
|
+
frames instead of building an unbounded queue or watching a growing delay.
|
|
106
|
+
- A consumer refcount + idle timeout stops the loop when nobody is watching; a
|
|
107
|
+
keep-alive restarts a crashed loop; switching devices retires the old child.
|
|
108
|
+
- **Tap or drag directly on the screen** to drive the device; a Back / Home /
|
|
109
|
+
Recents / Rotate / Power bar sits below it, and a device picker switches which
|
|
110
|
+
online device streams (it never boots one — use `device_boot` for that).
|
|
111
|
+
- **Security**: every stream route sits behind a loopback + trusted-browser
|
|
112
|
+
transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
|
|
113
|
+
so a LAN client cannot spoof localhost and a DNS-rebinding `Host` is rejected),
|
|
114
|
+
and the stream URL is an **HMAC-SHA256 capability** signed with a per-install
|
|
115
|
+
key (`~/.dsh/mobilecode/stream-access.key`, `0600`), expiring within 10 minutes
|
|
116
|
+
and re-minted automatically. Coordinates are normalized 0..1 of the streamed
|
|
117
|
+
frame, so one mapping serves every rotation.
|
|
90
118
|
- `device_log` — device logs: logcat `main`/`crash`/`events`/`kernel` buffers
|
|
91
119
|
(kernel = dmesg, needs adb root — works on emulators) with an optional
|
|
92
120
|
case-insensitive substring filter, capped line count.
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — host-side lifecycle manager for the one live Android stream.
|
|
3
|
+
*
|
|
4
|
+
* Ported (lean) from ZSeven-W/dsh-android (android-host.ts, MIT). The stream is
|
|
5
|
+
* in-process: one AdbFrameLoop per streamed serial, a consumer refcount with an
|
|
6
|
+
* idle timeout, and a keep-alive that restarts a crashed loop. Emulators and
|
|
7
|
+
* physical devices share this path — the serial is the only identity.
|
|
8
|
+
*
|
|
9
|
+
* The control surface takes NORMALIZED 0..1 coordinates of the streamed frame
|
|
10
|
+
* (the panel's <img> is scaled, so the browser knows fractions, not pixels) and
|
|
11
|
+
* maps them onto `adb shell input` pixels using the latest frame's own size.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as DeviceBuild from './device-build.js'
|
|
15
|
+
import { AdbFrameLoop } from './frame-source.js'
|
|
16
|
+
|
|
17
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000
|
|
18
|
+
const DEFAULT_RESTART_DELAY_MS = 5000
|
|
19
|
+
const KEEP_ALIVE_TICK_MS = 1000
|
|
20
|
+
const FIRST_FRAME_TIMEOUT_MS = 15000
|
|
21
|
+
const CONTROL_TIMEOUT_MS = 30000
|
|
22
|
+
const MAX_SWIPE_MS = 5000
|
|
23
|
+
|
|
24
|
+
/** Navigation/hardware buttons the panel may press. */
|
|
25
|
+
export const ANDROID_BUTTONS = {
|
|
26
|
+
home: 'KEYCODE_HOME',
|
|
27
|
+
back: 'KEYCODE_BACK',
|
|
28
|
+
recents: 'KEYCODE_APP_SWITCH',
|
|
29
|
+
power: 'KEYCODE_POWER',
|
|
30
|
+
volume_up: 'KEYCODE_VOLUME_UP',
|
|
31
|
+
volume_down: 'KEYCODE_VOLUME_DOWN',
|
|
32
|
+
menu: 'KEYCODE_MENU',
|
|
33
|
+
enter: 'KEYCODE_ENTER',
|
|
34
|
+
delete: 'KEYCODE_DEL',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Clockwise user_rotation cycle (Surface.ROTATION_0..270). */
|
|
38
|
+
export const ROTATION_CYCLE = [0, 1, 2, 3]
|
|
39
|
+
|
|
40
|
+
function errorMessage(error) {
|
|
41
|
+
return error instanceof Error ? error.message : String(error)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireNormalized(x, y) {
|
|
45
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) {
|
|
46
|
+
throw new RangeError('dsh-mobilecode: tap/drag coordinates must be normalized 0..1 of the streamed frame')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Lifecycle manager for the (single) in-process Android device stream. */
|
|
51
|
+
export class AndroidStreamHost {
|
|
52
|
+
#options
|
|
53
|
+
#loop
|
|
54
|
+
#starting
|
|
55
|
+
#launchQueue = Promise.resolve()
|
|
56
|
+
#consumers = 0
|
|
57
|
+
#keepAliveTimer
|
|
58
|
+
#idleTimer
|
|
59
|
+
#restarts = 0
|
|
60
|
+
#startedAt
|
|
61
|
+
#exitAt
|
|
62
|
+
#lastError
|
|
63
|
+
#lastSerial
|
|
64
|
+
#intentionalStop = false
|
|
65
|
+
#disposed = false
|
|
66
|
+
#frameSubscribers = new Set()
|
|
67
|
+
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.#options = {
|
|
70
|
+
restartDelayMs: options.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
|
|
71
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
|
72
|
+
firstFrameTimeoutMs: options.firstFrameTimeoutMs ?? FIRST_FRAME_TIMEOUT_MS,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get running() {
|
|
77
|
+
return this.#loop?.running === true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get streamedSerial() {
|
|
81
|
+
return this.running ? this.#loop?.serial : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get latestFrame() {
|
|
85
|
+
return this.#loop?.latestFrame
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Observe every decoded frame; the returned function unsubscribes. */
|
|
89
|
+
subscribeFrames(subscriber) {
|
|
90
|
+
this.#frameSubscribers.add(subscriber)
|
|
91
|
+
return () => {
|
|
92
|
+
this.#frameSubscribers.delete(subscriber)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Make sure the frame loop is live for `serial`. Concurrent callers share one
|
|
98
|
+
* launch; a call for a different serial retires the current loop first (one
|
|
99
|
+
* streamed device at a time).
|
|
100
|
+
*/
|
|
101
|
+
async ensureStreaming({ serial }) {
|
|
102
|
+
if (this.#disposed) throw new Error('dsh-mobilecode: the stream host is disposed')
|
|
103
|
+
if (typeof serial !== 'string' || serial === '') throw new TypeError('dsh-mobilecode: ensureStreaming requires a non-empty serial')
|
|
104
|
+
const startFor = async () => {
|
|
105
|
+
const current = this.#loop
|
|
106
|
+
if (current !== undefined && current.running && current.serial === serial) return this.#infoOf(current)
|
|
107
|
+
if (current !== undefined && current.serial !== serial) {
|
|
108
|
+
current.stop()
|
|
109
|
+
this.#loop = undefined
|
|
110
|
+
}
|
|
111
|
+
return this.#startFor(serial)
|
|
112
|
+
}
|
|
113
|
+
let starting = this.#starting
|
|
114
|
+
if (starting !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
await starting
|
|
117
|
+
} catch {
|
|
118
|
+
// A failed shared launch is settled; retry below.
|
|
119
|
+
}
|
|
120
|
+
if (this.running && this.#loop?.serial === serial) {
|
|
121
|
+
this.#armIdle()
|
|
122
|
+
return this.#infoOf(this.#loop)
|
|
123
|
+
}
|
|
124
|
+
starting = undefined
|
|
125
|
+
}
|
|
126
|
+
starting = this.#serializeLaunch(startFor)
|
|
127
|
+
this.#starting = starting
|
|
128
|
+
try {
|
|
129
|
+
const info = await starting
|
|
130
|
+
this.#lastError = undefined
|
|
131
|
+
this.#armIdle()
|
|
132
|
+
return info
|
|
133
|
+
} catch (error) {
|
|
134
|
+
this.#lastError = errorMessage(error)
|
|
135
|
+
throw error
|
|
136
|
+
} finally {
|
|
137
|
+
if (this.#starting === starting) this.#starting = undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Start the crash keep-alive loop (restarts an unintentionally dead loop). */
|
|
142
|
+
startKeepAlive() {
|
|
143
|
+
if (this.#keepAliveTimer !== undefined || this.#disposed) return
|
|
144
|
+
this.#keepAliveTimer = setInterval(() => {
|
|
145
|
+
void this.#keepAliveTick().catch(() => {})
|
|
146
|
+
}, KEEP_ALIVE_TICK_MS)
|
|
147
|
+
this.#keepAliveTimer.unref?.()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stopKeepAlive() {
|
|
151
|
+
if (this.#keepAliveTimer !== undefined) clearInterval(this.#keepAliveTimer)
|
|
152
|
+
this.#keepAliveTimer = undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Stop the stream intentionally (keep-alive will not fight it). */
|
|
156
|
+
async stop() {
|
|
157
|
+
this.#clearIdle()
|
|
158
|
+
this.#intentionalStop = true
|
|
159
|
+
this.#exitAt = undefined
|
|
160
|
+
const loop = this.#loop
|
|
161
|
+
this.#loop = undefined
|
|
162
|
+
this.#startedAt = undefined
|
|
163
|
+
loop?.stop()
|
|
164
|
+
await this.#starting?.catch(() => {})
|
|
165
|
+
const landed = this.#loop
|
|
166
|
+
if (landed !== undefined) {
|
|
167
|
+
this.#loop = undefined
|
|
168
|
+
landed.stop()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Hold the stream alive for one consumer; release exactly once. */
|
|
173
|
+
acquire() {
|
|
174
|
+
this.#consumers += 1
|
|
175
|
+
this.#armIdle()
|
|
176
|
+
let released = false
|
|
177
|
+
return () => {
|
|
178
|
+
if (released) return
|
|
179
|
+
released = true
|
|
180
|
+
this.#consumers = Math.max(0, this.#consumers - 1)
|
|
181
|
+
this.#armIdle()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
status() {
|
|
186
|
+
const loop = this.#loop
|
|
187
|
+
const frame = loop?.latestFrame
|
|
188
|
+
return {
|
|
189
|
+
running: this.running,
|
|
190
|
+
...(loop === undefined ? {} : { serial: loop.serial }),
|
|
191
|
+
restarts: this.#restarts,
|
|
192
|
+
...(this.#lastError === undefined ? {} : { lastError: this.#lastError }),
|
|
193
|
+
consumers: this.#consumers,
|
|
194
|
+
...(frame === undefined ? {} : { frameSequence: frame.sequence, lastFrameAt: frame.at, width: frame.width, height: frame.height }),
|
|
195
|
+
stderr: loop?.stderrLines ?? [],
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
dispose() {
|
|
200
|
+
this.#disposed = true
|
|
201
|
+
this.stopKeepAlive()
|
|
202
|
+
this.#frameSubscribers.clear()
|
|
203
|
+
return this.stop()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── control surface (normalized 0..1 of the streamed frame) ────────────────
|
|
207
|
+
|
|
208
|
+
async tap(serial, x, y) {
|
|
209
|
+
requireNormalized(x, y)
|
|
210
|
+
const point = await this.#pixels(serial, x, y)
|
|
211
|
+
await this.#shell(serial, ['input', 'tap', String(point.x), String(point.y)])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async drag(serial, drag) {
|
|
215
|
+
requireNormalized(drag.fromX, drag.fromY)
|
|
216
|
+
requireNormalized(drag.toX, drag.toY)
|
|
217
|
+
const from = await this.#pixels(serial, drag.fromX, drag.fromY)
|
|
218
|
+
const to = await this.#pixels(serial, drag.toX, drag.toY)
|
|
219
|
+
const durationMs = Math.min(MAX_SWIPE_MS, Math.max(20, Math.round((drag.duration ?? 0.3) * 1000)))
|
|
220
|
+
await this.#shell(serial, ['input', 'swipe', String(from.x), String(from.y), String(to.x), String(to.y), String(durationMs)])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async button(serial, name = 'home') {
|
|
224
|
+
const keycode = ANDROID_BUTTONS[name] ?? (/^KEYCODE_[A-Z0-9_]+$/.test(name) ? name : undefined)
|
|
225
|
+
if (keycode === undefined) throw new Error(`unknown button "${name}"; expected one of ${Object.keys(ANDROID_BUTTONS).join(', ')} or a KEYCODE_* name`)
|
|
226
|
+
await this.#shell(serial, ['input', 'keyevent', keycode])
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** ASCII via `input text`; non-ASCII via the ADBKeyboard IME, else refused. */
|
|
230
|
+
async type(serial, text) {
|
|
231
|
+
if (typeof text !== 'string' || text === '') throw new TypeError('dsh-mobilecode: type requires a non-empty text')
|
|
232
|
+
if (DeviceBuild.isAsciiInput(text)) {
|
|
233
|
+
await this.#shell(serial, ['input', 'text', DeviceBuild.escapeInputText(text)])
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (!(await DeviceBuild.adbKeyboardReady(serial))) {
|
|
237
|
+
throw new Error('the text contains non-ASCII characters that `input text` cannot deliver; install the ADBKeyboard IME (github.com/senzhk/ADBKeyBoard) and select it')
|
|
238
|
+
}
|
|
239
|
+
await DeviceBuild.typeViaAdbKeyboard(serial, text)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async rotate(serial, rotation) {
|
|
243
|
+
if (!ROTATION_CYCLE.includes(rotation)) throw new RangeError('dsh-mobilecode: rotation must be 0, 1, 2 or 3')
|
|
244
|
+
await this.#shell(serial, ['settings', 'put', 'system', 'accelerometer_rotation', '0'])
|
|
245
|
+
await this.#shell(serial, ['settings', 'put', 'system', 'user_rotation', String(rotation)])
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async getRotation(serial) {
|
|
249
|
+
try {
|
|
250
|
+
const value = Number((await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation'])).trim())
|
|
251
|
+
return ROTATION_CYCLE.includes(value) ? value : 0
|
|
252
|
+
} catch {
|
|
253
|
+
return 0
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The online device list for the panel picker (serial + kind). */
|
|
258
|
+
async listDevices() {
|
|
259
|
+
const rows = await DeviceBuild.devices()
|
|
260
|
+
return rows
|
|
261
|
+
.filter((row) => row.state === 'device')
|
|
262
|
+
.map((row) => ({ serial: row.serial, kind: row.serial.startsWith('emulator-') ? 'emulator' : 'physical' }))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #shell(serial, shell) {
|
|
266
|
+
const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell], { timeoutMs: CONTROL_TIMEOUT_MS }).exit
|
|
267
|
+
if (code !== 0) throw new Error(`adb shell ${shell.join(' ')} failed (exit ${code})`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async #keepAliveTick() {
|
|
271
|
+
if (this.#disposed || this.#intentionalStop) return
|
|
272
|
+
const exitAt = this.#exitAt
|
|
273
|
+
const serial = this.#lastSerial
|
|
274
|
+
if (exitAt === undefined || serial === undefined) return
|
|
275
|
+
if (Date.now() - exitAt < this.#options.restartDelayMs) return
|
|
276
|
+
this.#exitAt = undefined
|
|
277
|
+
this.#restarts += 1
|
|
278
|
+
try {
|
|
279
|
+
await this.ensureStreaming({ serial })
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.#lastError = errorMessage(error)
|
|
282
|
+
if (this.#exitAt === undefined) this.#exitAt = Date.now()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async #startFor(serial) {
|
|
287
|
+
if (this.#disposed) throw new Error('dsh-mobilecode: the stream host is disposed')
|
|
288
|
+
const online = await DeviceBuild.devices()
|
|
289
|
+
if (!online.some((row) => row.serial === serial && row.state === 'device')) {
|
|
290
|
+
const known = online.find((row) => row.serial === serial)
|
|
291
|
+
throw new Error(
|
|
292
|
+
known === undefined
|
|
293
|
+
? `no connected device has the serial ${serial}`
|
|
294
|
+
: `device ${serial} is ${known.state}, not ready to stream`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
const loop = new AdbFrameLoop(serial, {
|
|
298
|
+
onFrame: (frame) => {
|
|
299
|
+
for (const subscriber of this.#frameSubscribers) {
|
|
300
|
+
try {
|
|
301
|
+
subscriber(frame)
|
|
302
|
+
} catch {
|
|
303
|
+
// One broken consumer must not stall the fan-out.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
onExit: (detail) => {
|
|
308
|
+
if (this.#loop !== loop) return
|
|
309
|
+
this.#loop = undefined
|
|
310
|
+
this.#startedAt = undefined
|
|
311
|
+
this.#lastError = `the screencap loop for ${serial} died (${detail})`
|
|
312
|
+
this.#exitAt = Date.now()
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
this.#loop = loop
|
|
316
|
+
this.#lastSerial = serial
|
|
317
|
+
this.#intentionalStop = false
|
|
318
|
+
loop.reset()
|
|
319
|
+
loop.start()
|
|
320
|
+
const frame = await loop.waitForFrame(this.#options.firstFrameTimeoutMs)
|
|
321
|
+
if (frame === undefined) {
|
|
322
|
+
const stderr = loop.stderrLines.join('\n')
|
|
323
|
+
loop.stop()
|
|
324
|
+
if (this.#loop === loop) this.#loop = undefined
|
|
325
|
+
throw new Error(`no frame arrived from ${serial} within ${this.#options.firstFrameTimeoutMs} ms${stderr === '' ? '' : `: ${stderr}`}`)
|
|
326
|
+
}
|
|
327
|
+
this.#startedAt = Date.now()
|
|
328
|
+
this.#exitAt = undefined
|
|
329
|
+
return this.#infoOf(loop)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#infoOf(loop) {
|
|
333
|
+
const frame = loop.latestFrame
|
|
334
|
+
return { serial: loop.serial, ...(frame === undefined ? {} : { width: frame.width, height: frame.height }) }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Normalized frame coordinates → `input` pixels via the live frame size. */
|
|
338
|
+
async #pixels(serial, x, y) {
|
|
339
|
+
const frame = this.streamedSerial === serial ? this.latestFrame : undefined
|
|
340
|
+
if (frame !== undefined) return { x: Math.round(x * frame.width), y: Math.round(y * frame.height) }
|
|
341
|
+
const size = await screenSize(serial)
|
|
342
|
+
return { x: Math.round(x * size.width), y: Math.round(y * size.height) }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#armIdle() {
|
|
346
|
+
this.#clearIdle()
|
|
347
|
+
const idleMs = this.#options.idleTimeoutMs
|
|
348
|
+
if (idleMs <= 0) return
|
|
349
|
+
this.#idleTimer = setTimeout(() => {
|
|
350
|
+
this.#idleTimer = undefined
|
|
351
|
+
if (this.#consumers > 0) {
|
|
352
|
+
this.#armIdle()
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
void this.stop()
|
|
356
|
+
}, idleMs)
|
|
357
|
+
this.#idleTimer.unref?.()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#clearIdle() {
|
|
361
|
+
if (this.#idleTimer !== undefined) clearTimeout(this.#idleTimer)
|
|
362
|
+
this.#idleTimer = undefined
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#serializeLaunch(task) {
|
|
366
|
+
const run = this.#launchQueue.then(task, task)
|
|
367
|
+
this.#launchQueue = run.then(() => undefined, () => undefined)
|
|
368
|
+
return run
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Override-aware physical screen size for the non-streaming control fallback. */
|
|
373
|
+
async function screenSize(serial) {
|
|
374
|
+
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
|
|
375
|
+
const match = /Override size:\s*(\d+)x(\d+)/.exec(output) ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
|
|
376
|
+
if (!match) throw new Error(`cannot read the screen size of ${serial}`)
|
|
377
|
+
return { width: Number(match[1]), height: Number(match[2]) }
|
|
378
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -110,6 +110,17 @@ window.__ModuleLoader__.load({
|
|
|
110
110
|
.mc-section-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 2px 0 10px; }
|
|
111
111
|
.mc-section-head .mc-hint { flex: 1; min-width: 200px; }
|
|
112
112
|
.mc-section .mc-tabs { padding: 0; margin-bottom: 4px; }
|
|
113
|
+
/* ── live device stream ── */
|
|
114
|
+
.mc-live { display: flex; flex-direction: column; gap: 8px; }
|
|
115
|
+
.mc-live-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
116
|
+
.mc-live-bar select { background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.18)); border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; max-width: 200px; }
|
|
117
|
+
.mc-live-stage { position: relative; align-self: center; background: #000; border-radius: 10px; overflow: hidden; line-height: 0; box-shadow: inset 0 0 0 1px light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); }
|
|
118
|
+
.mc-live-stage img { display: block; max-width: 100%; max-height: 60vh; width: auto; height: auto; cursor: crosshair; touch-action: none; user-select: none; }
|
|
119
|
+
.mc-live-stage .mc-live-off { display: flex; align-items: center; justify-content: center; width: 260px; height: 460px; color: #9aa0a8; font-size: 13px; line-height: 1.5; text-align: center; }
|
|
120
|
+
.mc-live-nav { display: flex; align-items: center; justify-content: center; gap: 8px; }
|
|
121
|
+
.mc-live-nav button { border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); background: light-dark(#fff, #20242b); color: inherit; border-radius: 999px; width: 40px; height: 32px; cursor: pointer; font-size: 14px; }
|
|
122
|
+
.mc-live-nav button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
123
|
+
.mc-live-cap { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); text-align: center; }
|
|
113
124
|
`;
|
|
114
125
|
//#endregion
|
|
115
126
|
|
|
@@ -148,6 +159,20 @@ window.__ModuleLoader__.load({
|
|
|
148
159
|
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
149
160
|
return response.json();
|
|
150
161
|
};
|
|
162
|
+
// POST that surfaces the server's JSON error copy (stream routes return
|
|
163
|
+
// human-readable 4xx bodies the generic post() would flatten to "HTTP 409").
|
|
164
|
+
const streamPost = async (path, body) => {
|
|
165
|
+
const response = await fetch(API_BASE + path, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "content-type": "application/json" },
|
|
168
|
+
body: JSON.stringify(body ?? {}),
|
|
169
|
+
});
|
|
170
|
+
const text = await response.text();
|
|
171
|
+
let json;
|
|
172
|
+
try { json = JSON.parse(text); } catch { json = {}; }
|
|
173
|
+
if (!response.ok) throw new Error(json.error || ("HTTP " + response.status));
|
|
174
|
+
return json;
|
|
175
|
+
};
|
|
151
176
|
const copyText = async (text) => {
|
|
152
177
|
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
|
|
153
178
|
};
|
|
@@ -259,6 +284,7 @@ window.__ModuleLoader__.load({
|
|
|
259
284
|
);
|
|
260
285
|
}),
|
|
261
286
|
),
|
|
287
|
+
h(LiveDeviceCard, null),
|
|
262
288
|
platforms.map((platform) => {
|
|
263
289
|
const srv = server(platform);
|
|
264
290
|
const bld = build(platform);
|
|
@@ -333,6 +359,113 @@ window.__ModuleLoader__.load({
|
|
|
333
359
|
);
|
|
334
360
|
}
|
|
335
361
|
|
|
362
|
+
/** Live device stream: an <img> fed by the in-process multipart route, with
|
|
363
|
+
* tap/drag control and the Back/Home/Recents/rotate/power nav bar. */
|
|
364
|
+
function LiveDeviceCard() {
|
|
365
|
+
const [devices, setDevices] = useState([]);
|
|
366
|
+
const [serial, setSerial] = useState("");
|
|
367
|
+
const [streamUrl, setStreamUrl] = useState("");
|
|
368
|
+
const [error, setError] = useState("");
|
|
369
|
+
const imgRef = useRef(null);
|
|
370
|
+
const dragRef = useRef(null);
|
|
371
|
+
const grantTimer = useRef(null);
|
|
372
|
+
|
|
373
|
+
const grant = async (device) => {
|
|
374
|
+
if (!device) return;
|
|
375
|
+
try {
|
|
376
|
+
const r = await streamPost("/stream/grant", { device });
|
|
377
|
+
setStreamUrl(r.streamUrl);
|
|
378
|
+
setSerial(r.device);
|
|
379
|
+
setError("");
|
|
380
|
+
clearTimeout(grantTimer.current);
|
|
381
|
+
// Re-mint a minute before the 10-minute capability expires.
|
|
382
|
+
const ms = Math.max(30000, (r.expiresAt - Date.now()) - 60000);
|
|
383
|
+
grantTimer.current = setTimeout(() => { grant(r.device).catch(() => {}); }, ms);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
386
|
+
setStreamUrl("");
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const refreshDevices = async () => {
|
|
391
|
+
try {
|
|
392
|
+
const r = await streamPost("/stream/devices", {});
|
|
393
|
+
setDevices(r.devices);
|
|
394
|
+
setSerial((cur) => cur || (r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? ""));
|
|
395
|
+
} catch (err) {
|
|
396
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
refreshDevices();
|
|
402
|
+
const timer = setInterval(refreshDevices, 5000);
|
|
403
|
+
return () => { clearInterval(timer); clearTimeout(grantTimer.current); };
|
|
404
|
+
}, []);
|
|
405
|
+
|
|
406
|
+
// Auto-grant once a serial is known but no stream is up yet.
|
|
407
|
+
useEffect(() => { if (serial !== "" && streamUrl === "") grant(serial).catch(() => {}); }, [serial, streamUrl]);
|
|
408
|
+
|
|
409
|
+
const control = async (action) => {
|
|
410
|
+
if (serial === "") return;
|
|
411
|
+
try { await streamPost("/stream/control", { device: serial, action }); }
|
|
412
|
+
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const norm = (clientX, clientY) => {
|
|
416
|
+
const el = imgRef.current;
|
|
417
|
+
if (!el) return null;
|
|
418
|
+
const rect = el.getBoundingClientRect();
|
|
419
|
+
if (rect.width === 0 || rect.height === 0) return null;
|
|
420
|
+
return {
|
|
421
|
+
x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
|
|
422
|
+
y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
const onDown = (e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = p; };
|
|
426
|
+
const onUp = (e) => {
|
|
427
|
+
const start = dragRef.current;
|
|
428
|
+
dragRef.current = null;
|
|
429
|
+
const end = norm(e.clientX, e.clientY);
|
|
430
|
+
if (!start || !end) return;
|
|
431
|
+
if (Math.hypot(end.x - start.x, end.y - start.y) > 0.02) {
|
|
432
|
+
control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y });
|
|
433
|
+
} else {
|
|
434
|
+
control({ kind: "tap", x: end.x, y: end.y });
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
return h("div", { className: "mc-card mc-live" },
|
|
439
|
+
h("div", { className: "mc-card-head" },
|
|
440
|
+
h("span", null, "Live device"),
|
|
441
|
+
h("span", { className: "sp" }),
|
|
442
|
+
devices.length > 0
|
|
443
|
+
? h("select", {
|
|
444
|
+
value: serial,
|
|
445
|
+
onChange: (e) => { setSerial(e.target.value); setStreamUrl(""); setError(""); },
|
|
446
|
+
}, devices.map((d) => h("option", { key: d.serial, value: d.serial }, d.serial + (d.kind === "emulator" ? " · emu" : " · phone"))))
|
|
447
|
+
: h("span", { className: "mc-hint" }, "no device"),
|
|
448
|
+
h("button", { className: "mc-btn", disabled: serial === "", onClick: () => grant(serial) }, "Reconnect"),
|
|
449
|
+
),
|
|
450
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
451
|
+
h("div", { className: "mc-live-stage" },
|
|
452
|
+
streamUrl !== ""
|
|
453
|
+
? h("img", { ref: imgRef, src: streamUrl, alt: "device screen", draggable: false, onPointerDown: onDown, onPointerUp: onUp, onError: () => grant(serial) })
|
|
454
|
+
: h("div", { className: "mc-live-off" }, devices.length === 0
|
|
455
|
+
? "No device attached. Boot one with device_boot, or press Run app."
|
|
456
|
+
: "Connecting to " + serial + "…"),
|
|
457
|
+
),
|
|
458
|
+
h("div", { className: "mc-live-nav" },
|
|
459
|
+
h("button", { title: "Back", disabled: serial === "", onClick: () => control({ kind: "button", name: "back" }) }, "◁"),
|
|
460
|
+
h("button", { title: "Home", disabled: serial === "", onClick: () => control({ kind: "button", name: "home" }) }, "○"),
|
|
461
|
+
h("button", { title: "Recents", disabled: serial === "", onClick: () => control({ kind: "button", name: "recents" }) }, "▢"),
|
|
462
|
+
h("button", { title: "Rotate", disabled: serial === "", onClick: () => control({ kind: "rotate" }) }, "⟳"),
|
|
463
|
+
h("button", { title: "Power", disabled: serial === "", onClick: () => control({ kind: "button", name: "power" }) }, "⏻"),
|
|
464
|
+
),
|
|
465
|
+
h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
336
469
|
//#region setup UI (welcome + settings)
|
|
337
470
|
|
|
338
471
|
/** PaddleOCR status card: fetch /ocr, Install button, log tail. */
|
|
@@ -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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
19
19
|
import * as DeviceBuild from './device-build.js'
|
|
20
20
|
import * as UiTree from './uitree.js'
|
|
21
|
+
import * as FrameSource from './frame-source.js'
|
|
22
|
+
import * as StreamAccess from './stream-access.js'
|
|
23
|
+
import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
|
|
21
24
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
22
25
|
import * as Setup from './setup.js'
|
|
23
26
|
import { registerMobileSkill } from './skill.js'
|
|
@@ -70,11 +73,21 @@ function resolveDirectory(body, config) {
|
|
|
70
73
|
return process.cwd()
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function makeRoutes(engine, config) {
|
|
76
|
+
function makeRoutes(engine, config, stream) {
|
|
74
77
|
const guard = (req, res) => {
|
|
75
78
|
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
|
|
76
79
|
return true
|
|
77
80
|
}
|
|
81
|
+
// Stronger fence for the stream routes: loopback peer + loopback Host +
|
|
82
|
+
// Sec-Fetch-Site/Origin. POSTs (which mint capabilities) also require Origin.
|
|
83
|
+
const fence = (req, res, requireOrigin) => {
|
|
84
|
+
if (!StreamAccess.isTrustedRequest(req, requireOrigin)) { writeJson(res, 403, { error: 'forbidden: loopback trusted-browser only' }); return false }
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
const isPost = (req, res) => {
|
|
88
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
78
91
|
const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
|
|
79
92
|
const routes = [
|
|
80
93
|
// GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
|
|
@@ -290,6 +303,154 @@ function makeRoutes(engine, config) {
|
|
|
290
303
|
writeJson(res, 405, { error: 'method not allowed' })
|
|
291
304
|
},
|
|
292
305
|
},
|
|
306
|
+
// ── live device stream (panel) ──────────────────────────────────────────
|
|
307
|
+
// GET /api/dsh-mobilecode/stream?token=… — live multipart/x-mixed-replace PNG
|
|
308
|
+
// stream from the in-process frame loop. The <img> GET carries no Origin, so
|
|
309
|
+
// the fence here is loopback-only (requireOrigin false).
|
|
310
|
+
{
|
|
311
|
+
kind: 'exact',
|
|
312
|
+
path: API_BASE + '/stream',
|
|
313
|
+
handler: async (req, res) => {
|
|
314
|
+
if (!fence(req, res, false)) return
|
|
315
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
316
|
+
const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
|
|
317
|
+
const payload = await stream.access.verifyStreamToken(token)
|
|
318
|
+
if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
|
|
319
|
+
if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
|
|
320
|
+
const release = stream.host.acquire()
|
|
321
|
+
try {
|
|
322
|
+
await stream.host.ensureStreaming({ serial: payload.serial })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
release()
|
|
325
|
+
writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
const writer = new FrameSource.MultipartFrameWriter(res)
|
|
329
|
+
let finished = false
|
|
330
|
+
const teardown = () => {
|
|
331
|
+
if (finished) return
|
|
332
|
+
finished = true
|
|
333
|
+
unsubscribe()
|
|
334
|
+
writer.close()
|
|
335
|
+
release()
|
|
336
|
+
}
|
|
337
|
+
const unsubscribe = stream.host.subscribeFrames((frame) => {
|
|
338
|
+
// Frames for a different serial (after a device switch) must not leak
|
|
339
|
+
// into a capability minted for the old device.
|
|
340
|
+
if (stream.host.streamedSerial === payload.serial) writer.writeFrame(frame)
|
|
341
|
+
else teardown()
|
|
342
|
+
})
|
|
343
|
+
res.on('error', teardown)
|
|
344
|
+
res.on('close', teardown)
|
|
345
|
+
const latest = stream.host.latestFrame
|
|
346
|
+
if (latest !== undefined) writer.writeFrame(latest)
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
// POST /api/dsh-mobilecode/stream/grant {device?} — mint a fresh stream URL.
|
|
350
|
+
// Only starts the loop for an ONLINE device; never boots an emulator, never
|
|
351
|
+
// yanks the stream from a different streaming device.
|
|
352
|
+
{
|
|
353
|
+
kind: 'exact',
|
|
354
|
+
path: API_BASE + '/stream/grant',
|
|
355
|
+
handler: async (req, res) => {
|
|
356
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
357
|
+
const body = await readBody(req, res)
|
|
358
|
+
if (body === undefined) return
|
|
359
|
+
try {
|
|
360
|
+
const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
|
|
361
|
+
if (!serial) { writeJson(res, 409, { error: 'no device is streaming; pass a serial' }); return }
|
|
362
|
+
if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
|
|
363
|
+
if (stream.host.streamedSerial !== serial) {
|
|
364
|
+
const online = await stream.host.listDevices()
|
|
365
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
366
|
+
}
|
|
367
|
+
await stream.host.ensureStreaming({ serial })
|
|
368
|
+
const signed = await stream.access.signStreamToken(serial)
|
|
369
|
+
writeJson(res, 200, { ok: true, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`, expiresAt: signed.expiresAt, device: serial })
|
|
370
|
+
} catch (error) {
|
|
371
|
+
writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
// POST /api/dsh-mobilecode/stream/status {device?} — read-only snapshot;
|
|
376
|
+
// never starts a stream and never mints tokens.
|
|
377
|
+
{
|
|
378
|
+
kind: 'exact',
|
|
379
|
+
path: API_BASE + '/stream/status',
|
|
380
|
+
handler: async (req, res) => {
|
|
381
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
382
|
+
const body = await readBody(req, res)
|
|
383
|
+
if (body === undefined) return
|
|
384
|
+
const status = stream.host.status()
|
|
385
|
+
const filter = body.device
|
|
386
|
+
const running = status.running && status.serial !== undefined && (filter === undefined || filter === '' || status.serial === filter)
|
|
387
|
+
if (!running) { writeJson(res, 200, { ok: true, running: false }); return }
|
|
388
|
+
writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
// POST /api/dsh-mobilecode/stream/devices — online device list for the picker.
|
|
392
|
+
{
|
|
393
|
+
kind: 'exact',
|
|
394
|
+
path: API_BASE + '/stream/devices',
|
|
395
|
+
handler: async (req, res) => {
|
|
396
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
397
|
+
await readBody(req, res)
|
|
398
|
+
try {
|
|
399
|
+
const devices = await stream.host.listDevices()
|
|
400
|
+
const streamed = stream.host.streamedSerial
|
|
401
|
+
writeJson(res, 200, { ok: true, devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })) })
|
|
402
|
+
} catch (error) {
|
|
403
|
+
writeJson(res, 503, { error: error instanceof Error ? error.message : String(error) })
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
// POST /api/dsh-mobilecode/stream/control {device, action} — one control op.
|
|
408
|
+
// tap/drag coordinates are NORMALIZED 0..1 of the streamed frame.
|
|
409
|
+
{
|
|
410
|
+
kind: 'exact',
|
|
411
|
+
path: API_BASE + '/stream/control',
|
|
412
|
+
handler: async (req, res) => {
|
|
413
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
414
|
+
const body = await readBody(req, res)
|
|
415
|
+
if (body === undefined) return
|
|
416
|
+
const serial = body.device
|
|
417
|
+
if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
|
|
418
|
+
const action = body.action
|
|
419
|
+
if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
|
|
420
|
+
const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
|
|
421
|
+
if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
|
|
422
|
+
if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
|
|
423
|
+
if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
|
|
424
|
+
if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
|
|
425
|
+
if (stream.host.streamedSerial !== serial) {
|
|
426
|
+
const online = await stream.host.listDevices()
|
|
427
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
428
|
+
}
|
|
429
|
+
const release = stream.host.acquire()
|
|
430
|
+
try {
|
|
431
|
+
let result = { ok: true }
|
|
432
|
+
switch (action.kind) {
|
|
433
|
+
case 'tap': await stream.host.tap(serial, action.x, action.y); break
|
|
434
|
+
case 'drag': await stream.host.drag(serial, { fromX: action.fromX, fromY: action.fromY, toX: action.toX, toY: action.toY, ...(typeof action.durationMs === 'number' ? { duration: Math.min(5, action.durationMs / 1000) } : {}) }); break
|
|
435
|
+
case 'button': await stream.host.button(serial, action.name); break
|
|
436
|
+
case 'type': await stream.host.type(serial, action.text); break
|
|
437
|
+
case 'rotate': {
|
|
438
|
+
const current = await stream.host.getRotation(serial)
|
|
439
|
+
const next = ROTATION_CYCLE[(ROTATION_CYCLE.indexOf(current) + 1) % ROTATION_CYCLE.length]
|
|
440
|
+
await stream.host.rotate(serial, next)
|
|
441
|
+
result = { ok: true, rotation: next }
|
|
442
|
+
break
|
|
443
|
+
}
|
|
444
|
+
default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
445
|
+
}
|
|
446
|
+
writeJson(res, 200, result)
|
|
447
|
+
} catch (error) {
|
|
448
|
+
writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
449
|
+
} finally {
|
|
450
|
+
release()
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
},
|
|
293
454
|
]
|
|
294
455
|
return routes
|
|
295
456
|
}
|
|
@@ -908,6 +1069,57 @@ function deviceLaunchAppTool() {
|
|
|
908
1069
|
})
|
|
909
1070
|
}
|
|
910
1071
|
|
|
1072
|
+
function deviceStreamTool(host, access) {
|
|
1073
|
+
return defineTool({
|
|
1074
|
+
name: 'device_stream',
|
|
1075
|
+
description: 'Drive the live device screen stream the Devices panel shows. action=start begins the frame loop for an ' +
|
|
1076
|
+
'online device and returns a signed streamUrl; status reports whether it is running; stop tears it down. This is a ' +
|
|
1077
|
+
'human-panel feature — agents that just need to see the screen should use device_screen or device_ui_tree instead.',
|
|
1078
|
+
parameters: {
|
|
1079
|
+
action: { type: 'string', enum: ['status', 'start', 'stop'], description: 'What to do (default status).' },
|
|
1080
|
+
serial: { type: 'string', description: 'Device serial (start needs an online device; omit to use the first attached one).' },
|
|
1081
|
+
},
|
|
1082
|
+
output: {
|
|
1083
|
+
schema: {
|
|
1084
|
+
type: 'object',
|
|
1085
|
+
additionalProperties: false,
|
|
1086
|
+
properties: {
|
|
1087
|
+
action: { type: 'string', required: true },
|
|
1088
|
+
running: { type: 'boolean', required: true },
|
|
1089
|
+
serial: { type: 'string' },
|
|
1090
|
+
streamUrl: { type: 'string' },
|
|
1091
|
+
width: { type: 'integer' },
|
|
1092
|
+
height: { type: 'integer' },
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
render: (_args, value) => {
|
|
1096
|
+
const v = value ?? { action: 'status', running: false }
|
|
1097
|
+
const text = v.action === 'start' && v.streamUrl
|
|
1098
|
+
? `Streaming ${v.serial} (${v.width}x${v.height}) — ${v.streamUrl}`
|
|
1099
|
+
: `Stream ${v.action}: running=${v.running}${v.serial ? ` (${v.serial})` : ''}`
|
|
1100
|
+
return [{ type: 'text', text }]
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
async execute(args) {
|
|
1104
|
+
const action = args.action ?? 'status'
|
|
1105
|
+
if (action === 'stop') {
|
|
1106
|
+
await host.stop()
|
|
1107
|
+
return { action, running: false }
|
|
1108
|
+
}
|
|
1109
|
+
if (action === 'status') {
|
|
1110
|
+
const s = host.status()
|
|
1111
|
+
return { action, running: s.running, ...(s.serial !== undefined ? { serial: s.serial } : {}), ...(s.width !== undefined ? { width: s.width, height: s.height } : {}) }
|
|
1112
|
+
}
|
|
1113
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1114
|
+
const online = await host.listDevices()
|
|
1115
|
+
if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
|
|
1116
|
+
const info = await host.ensureStreaming({ serial })
|
|
1117
|
+
const signed = await access.signStreamToken(serial)
|
|
1118
|
+
return { action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}` }
|
|
1119
|
+
},
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
1122
|
+
|
|
911
1123
|
function deviceScreenTool(engine) {
|
|
912
1124
|
return defineTool({
|
|
913
1125
|
name: 'device_screen',
|
|
@@ -1360,6 +1572,8 @@ function guidance() {
|
|
|
1360
1572
|
'- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
|
|
1361
1573
|
'- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
|
|
1362
1574
|
'- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
|
|
1575
|
+
'- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
|
|
1576
|
+
' that just need to see the screen should prefer device_screen or device_ui_tree.',
|
|
1363
1577
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
1364
1578
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
1365
1579
|
'',
|
|
@@ -1388,8 +1602,11 @@ export function apply(ctx, config) {
|
|
|
1388
1602
|
})
|
|
1389
1603
|
|
|
1390
1604
|
const engine = new DevicePreviewEngine()
|
|
1605
|
+
const streamHost = new AndroidStreamHost()
|
|
1606
|
+
const streamAccess = new StreamAccess.StreamAccessController()
|
|
1391
1607
|
const handle = {
|
|
1392
1608
|
engine,
|
|
1609
|
+
stream: streamHost,
|
|
1393
1610
|
status: () => ({
|
|
1394
1611
|
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
1395
1612
|
servers: [...engine.servers.keys()],
|
|
@@ -1400,7 +1617,7 @@ export function apply(ctx, config) {
|
|
|
1400
1617
|
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
1401
1618
|
else ctx.mobilecode = handle
|
|
1402
1619
|
|
|
1403
|
-
const routes = makeRoutes(engine, config)
|
|
1620
|
+
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
|
|
1404
1621
|
let disposeRoutes
|
|
1405
1622
|
let disposeTools
|
|
1406
1623
|
let disposeSection
|
|
@@ -1411,6 +1628,7 @@ export function apply(ctx, config) {
|
|
|
1411
1628
|
if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
|
|
1412
1629
|
if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
|
|
1413
1630
|
if (!value.enabled) return
|
|
1631
|
+
streamHost.startKeepAlive()
|
|
1414
1632
|
if (value.announceToAgent) {
|
|
1415
1633
|
disposeSection = ctx.systemPrompt.section({
|
|
1416
1634
|
name: 'plugin:dsh-mobilecode',
|
|
@@ -1435,6 +1653,7 @@ export function apply(ctx, config) {
|
|
|
1435
1653
|
deviceActionTool(),
|
|
1436
1654
|
deviceAppsTool(),
|
|
1437
1655
|
deviceLaunchAppTool(),
|
|
1656
|
+
deviceStreamTool(streamHost, streamAccess),
|
|
1438
1657
|
deviceLogTool(engine),
|
|
1439
1658
|
deviceStatusTool(engine),
|
|
1440
1659
|
deviceInputTool(),
|
|
@@ -1445,6 +1664,7 @@ export function apply(ctx, config) {
|
|
|
1445
1664
|
|
|
1446
1665
|
ctx.effect(() => () => {
|
|
1447
1666
|
disposeSkill()
|
|
1667
|
+
void streamHost.dispose()
|
|
1448
1668
|
void engine.dispose()
|
|
1449
1669
|
}, 'dsh-mobilecode: engine')
|
|
1450
1670
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — capability tokens and the transport fence for the live
|
|
3
|
+
* stream routes.
|
|
4
|
+
*
|
|
5
|
+
* Ported from ZSeven-W/dsh-android (stream-access.ts, MIT), same security
|
|
6
|
+
* posture:
|
|
7
|
+
* - HMAC-SHA256 capabilities `base64url(payload).base64url(mac)`, signed with a
|
|
8
|
+
* 32-byte per-install key (`~/.dsh/mobilecode/stream-access.key`, created
|
|
9
|
+
* atomically); tokens expire within 10 minutes.
|
|
10
|
+
* - Every route applies the loopback / trusted-browser transport fence (peer
|
|
11
|
+
* address, loopback Host, Sec-Fetch-Site / Origin) BEFORE any capability is
|
|
12
|
+
* consulted — Host/Origin are caller-controlled, so a LAN client cannot spoof
|
|
13
|
+
* localhost and a DNS-rebinding Host is rejected.
|
|
14
|
+
*
|
|
15
|
+
* The screenshot-cache containment walk of the reference is not ported: this
|
|
16
|
+
* plugin serves only the live stream over its routes (device_screen writes PNGs
|
|
17
|
+
* to disk directly), so there is no arbitrary-path-serving surface to fence.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { HOME } from './setup.js'
|
|
25
|
+
|
|
26
|
+
/** Hard capability lifetime (tokens expire within 10 minutes). */
|
|
27
|
+
export const TOKEN_TTL_MS = 10 * 60 * 1000
|
|
28
|
+
|
|
29
|
+
const KEY_BYTES = 32
|
|
30
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
|
|
31
|
+
const MAX_TOKEN_LENGTH = 16 * 1024
|
|
32
|
+
/** Signing may run ahead of verification by this much before the TTL cap trips. */
|
|
33
|
+
const CLOCK_SKEW_MS = 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** adb device serials: `emulator-5554`, `RFCX123ABC`, or `host:port` for network adb. */
|
|
36
|
+
export const SERIAL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
37
|
+
|
|
38
|
+
function keyPath() {
|
|
39
|
+
return path.join(HOME, 'stream-access.key')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mac(key, payload) {
|
|
43
|
+
return createHmac('sha256', key).update(payload).digest()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeEqual(left, right) {
|
|
47
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Load or atomically create the per-install 32-byte signing key. */
|
|
51
|
+
export async function prepareStreamAccessKey() {
|
|
52
|
+
await mkdir(HOME, { recursive: true })
|
|
53
|
+
const file = keyPath()
|
|
54
|
+
if (existsSync(file)) {
|
|
55
|
+
const key = await readFile(file)
|
|
56
|
+
if (key.length === KEY_BYTES) return key
|
|
57
|
+
throw new Error('dsh-mobilecode: stream access key has an invalid length')
|
|
58
|
+
}
|
|
59
|
+
const candidate = randomBytes(KEY_BYTES)
|
|
60
|
+
try {
|
|
61
|
+
await writeFile(file, candidate, { flag: 'wx', mode: 0o600 })
|
|
62
|
+
return candidate
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code !== 'EEXIST') throw error
|
|
65
|
+
const key = await readFile(file)
|
|
66
|
+
if (key.length !== KEY_BYTES) throw new Error('dsh-mobilecode: stream access key has an invalid length')
|
|
67
|
+
return key
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseStreamPayload(value) {
|
|
72
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
73
|
+
if (
|
|
74
|
+
value.v !== 1
|
|
75
|
+
|| value.kind !== 'mobilecode-stream'
|
|
76
|
+
|| typeof value.serial !== 'string'
|
|
77
|
+
|| !SERIAL_PATTERN.test(value.serial)
|
|
78
|
+
|| typeof value.exp !== 'number'
|
|
79
|
+
|| !Number.isSafeInteger(value.exp)
|
|
80
|
+
) return undefined
|
|
81
|
+
return { v: 1, kind: 'mobilecode-stream', serial: value.serial, exp: value.exp }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** HMAC capability encoder/verifier for the live stream URL. */
|
|
85
|
+
export class StreamAccessController {
|
|
86
|
+
#routeCount = 0
|
|
87
|
+
#keyPromise
|
|
88
|
+
|
|
89
|
+
constructor(resolveKey = prepareStreamAccessKey) {
|
|
90
|
+
this.resolveKey = resolveKey
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether at least one HTTP carrier currently owns the routes. */
|
|
94
|
+
get routeAvailable() {
|
|
95
|
+
return this.#routeCount > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Mark one route attachment; the returned disposer removes it. */
|
|
99
|
+
attachRoute() {
|
|
100
|
+
this.#routeCount += 1
|
|
101
|
+
let active = true
|
|
102
|
+
return () => {
|
|
103
|
+
if (!active) return
|
|
104
|
+
active = false
|
|
105
|
+
this.#routeCount -= 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Mint a stream capability for one device serial. */
|
|
110
|
+
async signStreamToken(serial, options = {}) {
|
|
111
|
+
if (!SERIAL_PATTERN.test(serial)) throw new TypeError('dsh-mobilecode: signStreamToken requires a device serial')
|
|
112
|
+
const key = await this.#key()
|
|
113
|
+
const payload = { v: 1, kind: 'mobilecode-stream', serial, exp: Date.now() + this.#ttl(options.ttlMs) }
|
|
114
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
115
|
+
return { token: `${encoded}.${mac(key, encoded).toString('base64url')}`, expiresAt: payload.exp }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async verifyStreamToken(token) {
|
|
119
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
|
|
120
|
+
const [encoded, signature] = token.split('.')
|
|
121
|
+
if (encoded === undefined || signature === undefined) return undefined
|
|
122
|
+
const key = await this.#key().catch(() => undefined)
|
|
123
|
+
if (key === undefined) return undefined
|
|
124
|
+
let supplied
|
|
125
|
+
try {
|
|
126
|
+
supplied = Buffer.from(signature, 'base64url')
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
if (!safeEqual(mac(key, encoded), supplied)) return undefined
|
|
131
|
+
try {
|
|
132
|
+
const payload = parseStreamPayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
|
|
133
|
+
if (payload === undefined) return undefined
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
if (payload.exp <= now) return undefined
|
|
136
|
+
if (payload.exp - now > TOKEN_TTL_MS + CLOCK_SKEW_MS) return undefined
|
|
137
|
+
return payload
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#ttl(ttlMs) {
|
|
144
|
+
if (ttlMs === undefined || !Number.isFinite(ttlMs)) return TOKEN_TTL_MS
|
|
145
|
+
return Math.min(TOKEN_TTL_MS, Math.max(1, Math.floor(ttlMs)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#key() {
|
|
149
|
+
this.#keyPromise ??= this.resolveKey()
|
|
150
|
+
return this.#keyPromise
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── loopback / trusted-browser transport fence ───────────────────────────────
|
|
155
|
+
|
|
156
|
+
function isIpv4LoopbackAddress(address) {
|
|
157
|
+
const parts = address.split('.')
|
|
158
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Trust the transport peer, never forwarded or caller-controlled host data.
|
|
163
|
+
* Node may expose an IPv4 peer directly or as an IPv4-mapped IPv6 address,
|
|
164
|
+
* including the compact hexadecimal form used by some platforms.
|
|
165
|
+
*/
|
|
166
|
+
export function isLoopbackRemoteAddress(address) {
|
|
167
|
+
if (address === undefined) return false
|
|
168
|
+
const normalized = address.toLowerCase().split('%', 1)[0]
|
|
169
|
+
if (normalized === '::1' || isIpv4LoopbackAddress(normalized)) return true
|
|
170
|
+
if (!normalized.startsWith('::ffff:')) return false
|
|
171
|
+
const mapped = normalized.slice('::ffff:'.length)
|
|
172
|
+
if (isIpv4LoopbackAddress(mapped)) return true
|
|
173
|
+
const hexadecimal = /^([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(mapped)
|
|
174
|
+
return hexadecimal !== null && (Number.parseInt(hexadecimal[1], 16) >>> 8) === 127
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLoopbackHostname(hostname) {
|
|
178
|
+
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
179
|
+
return isIpv4LoopbackAddress(hostname)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function requestAuthority(req) {
|
|
183
|
+
const host = req.headers.host
|
|
184
|
+
if (typeof host !== 'string') return undefined
|
|
185
|
+
try {
|
|
186
|
+
const parsed = new URL(`http://${host}`)
|
|
187
|
+
if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' || parsed.username !== '' || parsed.password !== '') {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
return parsed
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isLoopbackRequest(req) {
|
|
197
|
+
if (!isLoopbackRemoteAddress(req.socket?.remoteAddress)) return false
|
|
198
|
+
const authority = requestAuthority(req)
|
|
199
|
+
return authority !== undefined && isLoopbackHostname(authority.hostname)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isTrustedBrowserRequest(req, requireOrigin) {
|
|
203
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
204
|
+
const origin = req.headers.origin
|
|
205
|
+
if (origin === undefined) return !requireOrigin
|
|
206
|
+
if (typeof origin !== 'string') return false
|
|
207
|
+
const authority = requestAuthority(req)
|
|
208
|
+
if (authority === undefined) return false
|
|
209
|
+
try {
|
|
210
|
+
const parsed = new URL(origin)
|
|
211
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === authority.host
|
|
212
|
+
} catch {
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The transport fence applied to every stream route. */
|
|
218
|
+
export function isTrustedRequest(req, requireOrigin = false) {
|
|
219
|
+
return isLoopbackRequest(req) && isTrustedBrowserRequest(req, requireOrigin)
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|