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 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.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {