dsh-mobilecode 0.2.1 → 0.4.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 +39 -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 +234 -6
- package/lib/stream-access.js +220 -0
- package/lib/vision.js +130 -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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
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'
|
|
24
|
+
import * as Vision from './vision.js'
|
|
21
25
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
22
26
|
import * as Setup from './setup.js'
|
|
23
27
|
import { registerMobileSkill } from './skill.js'
|
|
@@ -70,11 +74,21 @@ function resolveDirectory(body, config) {
|
|
|
70
74
|
return process.cwd()
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function makeRoutes(engine, config) {
|
|
77
|
+
function makeRoutes(engine, config, stream) {
|
|
74
78
|
const guard = (req, res) => {
|
|
75
79
|
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
|
|
76
80
|
return true
|
|
77
81
|
}
|
|
82
|
+
// Stronger fence for the stream routes: loopback peer + loopback Host +
|
|
83
|
+
// Sec-Fetch-Site/Origin. POSTs (which mint capabilities) also require Origin.
|
|
84
|
+
const fence = (req, res, requireOrigin) => {
|
|
85
|
+
if (!StreamAccess.isTrustedRequest(req, requireOrigin)) { writeJson(res, 403, { error: 'forbidden: loopback trusted-browser only' }); return false }
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
const isPost = (req, res) => {
|
|
89
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
78
92
|
const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
|
|
79
93
|
const routes = [
|
|
80
94
|
// GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
|
|
@@ -290,6 +304,154 @@ function makeRoutes(engine, config) {
|
|
|
290
304
|
writeJson(res, 405, { error: 'method not allowed' })
|
|
291
305
|
},
|
|
292
306
|
},
|
|
307
|
+
// ── live device stream (panel) ──────────────────────────────────────────
|
|
308
|
+
// GET /api/dsh-mobilecode/stream?token=… — live multipart/x-mixed-replace PNG
|
|
309
|
+
// stream from the in-process frame loop. The <img> GET carries no Origin, so
|
|
310
|
+
// the fence here is loopback-only (requireOrigin false).
|
|
311
|
+
{
|
|
312
|
+
kind: 'exact',
|
|
313
|
+
path: API_BASE + '/stream',
|
|
314
|
+
handler: async (req, res) => {
|
|
315
|
+
if (!fence(req, res, false)) return
|
|
316
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
317
|
+
const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
|
|
318
|
+
const payload = await stream.access.verifyStreamToken(token)
|
|
319
|
+
if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
|
|
320
|
+
if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
|
|
321
|
+
const release = stream.host.acquire()
|
|
322
|
+
try {
|
|
323
|
+
await stream.host.ensureStreaming({ serial: payload.serial })
|
|
324
|
+
} catch (error) {
|
|
325
|
+
release()
|
|
326
|
+
writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const writer = new FrameSource.MultipartFrameWriter(res)
|
|
330
|
+
let finished = false
|
|
331
|
+
const teardown = () => {
|
|
332
|
+
if (finished) return
|
|
333
|
+
finished = true
|
|
334
|
+
unsubscribe()
|
|
335
|
+
writer.close()
|
|
336
|
+
release()
|
|
337
|
+
}
|
|
338
|
+
const unsubscribe = stream.host.subscribeFrames((frame) => {
|
|
339
|
+
// Frames for a different serial (after a device switch) must not leak
|
|
340
|
+
// into a capability minted for the old device.
|
|
341
|
+
if (stream.host.streamedSerial === payload.serial) writer.writeFrame(frame)
|
|
342
|
+
else teardown()
|
|
343
|
+
})
|
|
344
|
+
res.on('error', teardown)
|
|
345
|
+
res.on('close', teardown)
|
|
346
|
+
const latest = stream.host.latestFrame
|
|
347
|
+
if (latest !== undefined) writer.writeFrame(latest)
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
// POST /api/dsh-mobilecode/stream/grant {device?} — mint a fresh stream URL.
|
|
351
|
+
// Only starts the loop for an ONLINE device; never boots an emulator, never
|
|
352
|
+
// yanks the stream from a different streaming device.
|
|
353
|
+
{
|
|
354
|
+
kind: 'exact',
|
|
355
|
+
path: API_BASE + '/stream/grant',
|
|
356
|
+
handler: async (req, res) => {
|
|
357
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
358
|
+
const body = await readBody(req, res)
|
|
359
|
+
if (body === undefined) return
|
|
360
|
+
try {
|
|
361
|
+
const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
|
|
362
|
+
if (!serial) { writeJson(res, 409, { error: 'no device is streaming; pass a serial' }); return }
|
|
363
|
+
if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
|
|
364
|
+
if (stream.host.streamedSerial !== serial) {
|
|
365
|
+
const online = await stream.host.listDevices()
|
|
366
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
367
|
+
}
|
|
368
|
+
await stream.host.ensureStreaming({ serial })
|
|
369
|
+
const signed = await stream.access.signStreamToken(serial)
|
|
370
|
+
writeJson(res, 200, { ok: true, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`, expiresAt: signed.expiresAt, device: serial })
|
|
371
|
+
} catch (error) {
|
|
372
|
+
writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
// POST /api/dsh-mobilecode/stream/status {device?} — read-only snapshot;
|
|
377
|
+
// never starts a stream and never mints tokens.
|
|
378
|
+
{
|
|
379
|
+
kind: 'exact',
|
|
380
|
+
path: API_BASE + '/stream/status',
|
|
381
|
+
handler: async (req, res) => {
|
|
382
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
383
|
+
const body = await readBody(req, res)
|
|
384
|
+
if (body === undefined) return
|
|
385
|
+
const status = stream.host.status()
|
|
386
|
+
const filter = body.device
|
|
387
|
+
const running = status.running && status.serial !== undefined && (filter === undefined || filter === '' || status.serial === filter)
|
|
388
|
+
if (!running) { writeJson(res, 200, { ok: true, running: false }); return }
|
|
389
|
+
writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
// POST /api/dsh-mobilecode/stream/devices — online device list for the picker.
|
|
393
|
+
{
|
|
394
|
+
kind: 'exact',
|
|
395
|
+
path: API_BASE + '/stream/devices',
|
|
396
|
+
handler: async (req, res) => {
|
|
397
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
398
|
+
await readBody(req, res)
|
|
399
|
+
try {
|
|
400
|
+
const devices = await stream.host.listDevices()
|
|
401
|
+
const streamed = stream.host.streamedSerial
|
|
402
|
+
writeJson(res, 200, { ok: true, devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })) })
|
|
403
|
+
} catch (error) {
|
|
404
|
+
writeJson(res, 503, { error: error instanceof Error ? error.message : String(error) })
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
// POST /api/dsh-mobilecode/stream/control {device, action} — one control op.
|
|
409
|
+
// tap/drag coordinates are NORMALIZED 0..1 of the streamed frame.
|
|
410
|
+
{
|
|
411
|
+
kind: 'exact',
|
|
412
|
+
path: API_BASE + '/stream/control',
|
|
413
|
+
handler: async (req, res) => {
|
|
414
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
415
|
+
const body = await readBody(req, res)
|
|
416
|
+
if (body === undefined) return
|
|
417
|
+
const serial = body.device
|
|
418
|
+
if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
|
|
419
|
+
const action = body.action
|
|
420
|
+
if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
|
|
421
|
+
const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
|
|
422
|
+
if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
|
|
423
|
+
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 }
|
|
424
|
+
if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
|
|
425
|
+
if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
|
|
426
|
+
if (stream.host.streamedSerial !== serial) {
|
|
427
|
+
const online = await stream.host.listDevices()
|
|
428
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
429
|
+
}
|
|
430
|
+
const release = stream.host.acquire()
|
|
431
|
+
try {
|
|
432
|
+
let result = { ok: true }
|
|
433
|
+
switch (action.kind) {
|
|
434
|
+
case 'tap': await stream.host.tap(serial, action.x, action.y); break
|
|
435
|
+
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
|
|
436
|
+
case 'button': await stream.host.button(serial, action.name); break
|
|
437
|
+
case 'type': await stream.host.type(serial, action.text); break
|
|
438
|
+
case 'rotate': {
|
|
439
|
+
const current = await stream.host.getRotation(serial)
|
|
440
|
+
const next = ROTATION_CYCLE[(ROTATION_CYCLE.indexOf(current) + 1) % ROTATION_CYCLE.length]
|
|
441
|
+
await stream.host.rotate(serial, next)
|
|
442
|
+
result = { ok: true, rotation: next }
|
|
443
|
+
break
|
|
444
|
+
}
|
|
445
|
+
default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
446
|
+
}
|
|
447
|
+
writeJson(res, 200, result)
|
|
448
|
+
} catch (error) {
|
|
449
|
+
writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
450
|
+
} finally {
|
|
451
|
+
release()
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
},
|
|
293
455
|
]
|
|
294
456
|
return routes
|
|
295
457
|
}
|
|
@@ -908,7 +1070,58 @@ function deviceLaunchAppTool() {
|
|
|
908
1070
|
})
|
|
909
1071
|
}
|
|
910
1072
|
|
|
911
|
-
function
|
|
1073
|
+
function deviceStreamTool(host, access) {
|
|
1074
|
+
return defineTool({
|
|
1075
|
+
name: 'device_stream',
|
|
1076
|
+
description: 'Drive the live device screen stream the Devices panel shows. action=start begins the frame loop for an ' +
|
|
1077
|
+
'online device and returns a signed streamUrl; status reports whether it is running; stop tears it down. This is a ' +
|
|
1078
|
+
'human-panel feature — agents that just need to see the screen should use device_screen or device_ui_tree instead.',
|
|
1079
|
+
parameters: {
|
|
1080
|
+
action: { type: 'string', enum: ['status', 'start', 'stop'], description: 'What to do (default status).' },
|
|
1081
|
+
serial: { type: 'string', description: 'Device serial (start needs an online device; omit to use the first attached one).' },
|
|
1082
|
+
},
|
|
1083
|
+
output: {
|
|
1084
|
+
schema: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
additionalProperties: false,
|
|
1087
|
+
properties: {
|
|
1088
|
+
action: { type: 'string', required: true },
|
|
1089
|
+
running: { type: 'boolean', required: true },
|
|
1090
|
+
serial: { type: 'string' },
|
|
1091
|
+
streamUrl: { type: 'string' },
|
|
1092
|
+
width: { type: 'integer' },
|
|
1093
|
+
height: { type: 'integer' },
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
render: (_args, value) => {
|
|
1097
|
+
const v = value ?? { action: 'status', running: false }
|
|
1098
|
+
const text = v.action === 'start' && v.streamUrl
|
|
1099
|
+
? `Streaming ${v.serial} (${v.width}x${v.height}) — ${v.streamUrl}`
|
|
1100
|
+
: `Stream ${v.action}: running=${v.running}${v.serial ? ` (${v.serial})` : ''}`
|
|
1101
|
+
return [{ type: 'text', text }]
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
async execute(args) {
|
|
1105
|
+
const action = args.action ?? 'status'
|
|
1106
|
+
if (action === 'stop') {
|
|
1107
|
+
await host.stop()
|
|
1108
|
+
return { action, running: false }
|
|
1109
|
+
}
|
|
1110
|
+
if (action === 'status') {
|
|
1111
|
+
const s = host.status()
|
|
1112
|
+
return { action, running: s.running, ...(s.serial !== undefined ? { serial: s.serial } : {}), ...(s.width !== undefined ? { width: s.width, height: s.height } : {}) }
|
|
1113
|
+
}
|
|
1114
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1115
|
+
const online = await host.listDevices()
|
|
1116
|
+
if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
|
|
1117
|
+
const info = await host.ensureStreaming({ serial })
|
|
1118
|
+
const signed = await access.signStreamToken(serial)
|
|
1119
|
+
return { action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}` }
|
|
1120
|
+
},
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function deviceScreenTool(engine, vision) {
|
|
912
1125
|
return defineTool({
|
|
913
1126
|
name: 'device_screen',
|
|
914
1127
|
description: 'See what is on an attached Android device right now: captures the screen as a PNG file, dumps the UI ' +
|
|
@@ -965,6 +1178,7 @@ function deviceScreenTool(engine) {
|
|
|
965
1178
|
},
|
|
966
1179
|
},
|
|
967
1180
|
ocrError: { type: 'string' },
|
|
1181
|
+
image: Vision.IMAGE_REF_SCHEMA,
|
|
968
1182
|
},
|
|
969
1183
|
},
|
|
970
1184
|
render: (_args, value) => {
|
|
@@ -988,10 +1202,13 @@ function deviceScreenTool(engine) {
|
|
|
988
1202
|
if (v.ocr.length > 40) lines.push(` … and ${v.ocr.length - 40} more`)
|
|
989
1203
|
}
|
|
990
1204
|
if (!v.ui?.length && !v.ocr?.length) lines.push('No text found on screen.')
|
|
991
|
-
|
|
1205
|
+
const blocks = [{ type: 'text', text: lines.join('\n') }]
|
|
1206
|
+
// When the routed model accepts images, the screenshot rides along as a
|
|
1207
|
+
// real image block so the model SEES the screen (see lib/vision.js).
|
|
1208
|
+
return Vision.appendImageBlock(blocks, v)
|
|
992
1209
|
},
|
|
993
1210
|
},
|
|
994
|
-
async execute(args) {
|
|
1211
|
+
async execute(args, exec) {
|
|
995
1212
|
const serial = await requireAndroidDevice(args.serial)
|
|
996
1213
|
const png = await DeviceBuild.screenCapture(serial, args.directory)
|
|
997
1214
|
const [ui, foreground, size] = await Promise.all([
|
|
@@ -1015,6 +1232,8 @@ function deviceScreenTool(engine) {
|
|
|
1015
1232
|
else out.ocrError = 'PaddleOCR returned no text (or failed silently)'
|
|
1016
1233
|
}
|
|
1017
1234
|
}
|
|
1235
|
+
const image = await Vision.maybeAttachScreenshot(vision, png, exec)
|
|
1236
|
+
if (image !== undefined) out.image = image
|
|
1018
1237
|
return out
|
|
1019
1238
|
},
|
|
1020
1239
|
})
|
|
@@ -1360,6 +1579,8 @@ function guidance() {
|
|
|
1360
1579
|
'- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
|
|
1361
1580
|
'- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
|
|
1362
1581
|
'- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
|
|
1582
|
+
'- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
|
|
1583
|
+
' that just need to see the screen should prefer device_screen or device_ui_tree.',
|
|
1363
1584
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
1364
1585
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
1365
1586
|
'',
|
|
@@ -1388,8 +1609,12 @@ export function apply(ctx, config) {
|
|
|
1388
1609
|
})
|
|
1389
1610
|
|
|
1390
1611
|
const engine = new DevicePreviewEngine()
|
|
1612
|
+
const streamHost = new AndroidStreamHost()
|
|
1613
|
+
const streamAccess = new StreamAccess.StreamAccessController()
|
|
1614
|
+
const vision = Vision.resolveVisionServices(ctx)
|
|
1391
1615
|
const handle = {
|
|
1392
1616
|
engine,
|
|
1617
|
+
stream: streamHost,
|
|
1393
1618
|
status: () => ({
|
|
1394
1619
|
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
1395
1620
|
servers: [...engine.servers.keys()],
|
|
@@ -1400,7 +1625,7 @@ export function apply(ctx, config) {
|
|
|
1400
1625
|
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
1401
1626
|
else ctx.mobilecode = handle
|
|
1402
1627
|
|
|
1403
|
-
const routes = makeRoutes(engine, config)
|
|
1628
|
+
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
|
|
1404
1629
|
let disposeRoutes
|
|
1405
1630
|
let disposeTools
|
|
1406
1631
|
let disposeSection
|
|
@@ -1411,6 +1636,7 @@ export function apply(ctx, config) {
|
|
|
1411
1636
|
if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
|
|
1412
1637
|
if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
|
|
1413
1638
|
if (!value.enabled) return
|
|
1639
|
+
streamHost.startKeepAlive()
|
|
1414
1640
|
if (value.announceToAgent) {
|
|
1415
1641
|
disposeSection = ctx.systemPrompt.section({
|
|
1416
1642
|
name: 'plugin:dsh-mobilecode',
|
|
@@ -1426,7 +1652,7 @@ export function apply(ctx, config) {
|
|
|
1426
1652
|
const disposers = [
|
|
1427
1653
|
deviceRunTool(engine, config),
|
|
1428
1654
|
deviceDetectTool(engine, config),
|
|
1429
|
-
deviceScreenTool(engine),
|
|
1655
|
+
deviceScreenTool(engine, vision),
|
|
1430
1656
|
deviceUiTreeTool(),
|
|
1431
1657
|
deviceTapElementTool(),
|
|
1432
1658
|
deviceWaitForTool(),
|
|
@@ -1435,6 +1661,7 @@ export function apply(ctx, config) {
|
|
|
1435
1661
|
deviceActionTool(),
|
|
1436
1662
|
deviceAppsTool(),
|
|
1437
1663
|
deviceLaunchAppTool(),
|
|
1664
|
+
deviceStreamTool(streamHost, streamAccess),
|
|
1438
1665
|
deviceLogTool(engine),
|
|
1439
1666
|
deviceStatusTool(engine),
|
|
1440
1667
|
deviceInputTool(),
|
|
@@ -1445,6 +1672,7 @@ export function apply(ctx, config) {
|
|
|
1445
1672
|
|
|
1446
1673
|
ctx.effect(() => () => {
|
|
1447
1674
|
disposeSkill()
|
|
1675
|
+
void streamHost.dispose()
|
|
1448
1676
|
void engine.dispose()
|
|
1449
1677
|
}, 'dsh-mobilecode: engine')
|
|
1450
1678
|
|