dsh-mobilecode 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/lib/android-stream.js +378 -0
- package/lib/client.js +133 -0
- package/lib/device-build.js +51 -0
- package/lib/frame-source.js +274 -0
- package/lib/index.js +549 -7
- package/lib/stream-access.js +220 -0
- package/package.json +1 -1
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
|
}
|
|
@@ -583,9 +744,21 @@ function deviceInputTool() {
|
|
|
583
744
|
}
|
|
584
745
|
case 'text': {
|
|
585
746
|
if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
747
|
+
if (DeviceBuild.isAsciiInput(args.text)) {
|
|
748
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
|
|
749
|
+
return { serial, action, sent: `text "${args.text}"` }
|
|
750
|
+
}
|
|
751
|
+
// Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
|
|
752
|
+
// ADBKeyboard IME is the only adb path. Refuse with the fix, never mangle.
|
|
753
|
+
if (!(await DeviceBuild.adbKeyboardReady(serial))) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`device_input cannot type non-ASCII text ("${args.text}") over plain adb. Install ADBKeyboard `
|
|
756
|
+
+ 'on the device (https://github.com/senzhk/ADBKeyBoard), enable it (ime enable/set '
|
|
757
|
+
+ 'com.android.adbkeyboard/.AdbIME), then retry — this tool then types via its base64 broadcast.',
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
await DeviceBuild.typeViaAdbKeyboard(serial, args.text)
|
|
761
|
+
return { serial, action, sent: `text "${args.text}" (ADBKeyboard)` }
|
|
589
762
|
}
|
|
590
763
|
case 'key': {
|
|
591
764
|
const raw = String(args.key ?? '')
|
|
@@ -601,6 +774,352 @@ function deviceInputTool() {
|
|
|
601
774
|
})
|
|
602
775
|
}
|
|
603
776
|
|
|
777
|
+
/** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
|
|
778
|
+
async function ocrHasText(serial, wantedLower) {
|
|
779
|
+
if (!DeviceBuild.ocrPython()) return undefined
|
|
780
|
+
const png = await DeviceBuild.screenCapture(serial)
|
|
781
|
+
if (!png) return undefined
|
|
782
|
+
const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
|
|
783
|
+
return ocr.some((item) => String(item.text).toLowerCase().includes(wantedLower))
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function deviceWaitForTool() {
|
|
787
|
+
return defineTool({
|
|
788
|
+
name: 'device_wait_for',
|
|
789
|
+
description: 'Wait for on-screen text to appear or disappear. Polls the uiautomator tree every ~600 ms; when the tree ' +
|
|
790
|
+
'carries no labels (WebView/Compose/canvas) it falls back to local PaddleOCR. A timeout is a normal matched:false ' +
|
|
791
|
+
'result, never an error — one call replaces an agent-side poll loop.',
|
|
792
|
+
parameters: {
|
|
793
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
794
|
+
text: { type: 'string', description: 'Text to wait for (case-insensitive substring).' },
|
|
795
|
+
mode: { type: 'string', enum: ['appear', 'disappear'], description: 'appear (default) waits for the text; disappear waits for it to go away.' },
|
|
796
|
+
timeout_ms: { type: 'integer', description: 'Max wait in ms (default 10000, max 60000).' },
|
|
797
|
+
},
|
|
798
|
+
output: {
|
|
799
|
+
schema: {
|
|
800
|
+
type: 'object',
|
|
801
|
+
additionalProperties: false,
|
|
802
|
+
properties: {
|
|
803
|
+
serial: { type: 'string', required: true },
|
|
804
|
+
text: { type: 'string', required: true },
|
|
805
|
+
mode: { type: 'string', required: true },
|
|
806
|
+
matched: { type: 'boolean', required: true },
|
|
807
|
+
waited_ms: { type: 'integer', required: true },
|
|
808
|
+
source: { type: 'string', required: true },
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
render: (_args, value) => {
|
|
812
|
+
const v = value ?? { serial: '', text: '', mode: 'appear', matched: false, waited_ms: 0, source: 'ui_tree' }
|
|
813
|
+
return [{ type: 'text', text: `${v.matched ? 'MATCHED' : 'TIMEOUT'}: "${v.text}" ${v.mode} (${v.source}) after ${v.waited_ms}ms on ${v.serial}` }]
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
async execute(args) {
|
|
817
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
818
|
+
const text = String(args.text ?? '').trim()
|
|
819
|
+
if (text === '') throw new Error('device_wait_for requires a non-empty text.')
|
|
820
|
+
const mode = args.mode === 'disappear' ? 'disappear' : 'appear'
|
|
821
|
+
const timeout = Math.min(Math.max(args.timeout_ms ?? 10_000, 500), 60_000)
|
|
822
|
+
const wanted = text.toLowerCase()
|
|
823
|
+
const start = Date.now()
|
|
824
|
+
const deadline = start + timeout
|
|
825
|
+
let source = 'ui_tree'
|
|
826
|
+
for (;;) {
|
|
827
|
+
let labels
|
|
828
|
+
try {
|
|
829
|
+
labels = UiTree.collectLabels((await UiTree.readUiTree(serial)).roots)
|
|
830
|
+
} catch {
|
|
831
|
+
labels = undefined
|
|
832
|
+
}
|
|
833
|
+
let present
|
|
834
|
+
if (labels !== undefined && labels.length > 0) {
|
|
835
|
+
source = 'ui_tree'
|
|
836
|
+
present = labels.some((label) => label.toLowerCase().includes(wanted))
|
|
837
|
+
} else {
|
|
838
|
+
const ocr = await ocrHasText(serial, wanted)
|
|
839
|
+
if (ocr === undefined) { source = 'ui_tree'; present = false }
|
|
840
|
+
else { source = 'ocr'; present = ocr }
|
|
841
|
+
}
|
|
842
|
+
const matched = mode === 'appear' ? present : !present
|
|
843
|
+
const waited = Math.min(timeout, Date.now() - start)
|
|
844
|
+
if (matched) return { serial, text, mode, matched: true, waited_ms: waited, source }
|
|
845
|
+
if (Date.now() >= deadline) return { serial, text, mode, matched: false, waited_ms: timeout, source }
|
|
846
|
+
await new Promise((resolve) => setTimeout(resolve, source === 'ocr' ? 2000 : 600))
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function deviceBootTool() {
|
|
853
|
+
return defineTool({
|
|
854
|
+
name: 'device_boot',
|
|
855
|
+
description: 'Boot an Android emulator AVD by name (from device_status.avds) and wait until it finishes booting. ' +
|
|
856
|
+
'If an emulator for that AVD is already running it is adopted. This is the device-centric boot — device_run is the ' +
|
|
857
|
+
'project-centric build+install+launch.',
|
|
858
|
+
parameters: {
|
|
859
|
+
avd: { type: 'string', description: 'AVD name to boot.' },
|
|
860
|
+
timeout_ms: { type: 'integer', description: 'Max wait for boot in ms (default 180000, max 600000).' },
|
|
861
|
+
},
|
|
862
|
+
output: {
|
|
863
|
+
schema: {
|
|
864
|
+
type: 'object',
|
|
865
|
+
additionalProperties: false,
|
|
866
|
+
properties: {
|
|
867
|
+
serial: { type: 'string', required: true },
|
|
868
|
+
avd: { type: 'string', required: true },
|
|
869
|
+
booted: { type: 'boolean', required: true },
|
|
870
|
+
alreadyRunning: { type: 'boolean', required: true },
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
render: (_args, value) => {
|
|
874
|
+
const v = value ?? { serial: '', avd: '', booted: false, alreadyRunning: false }
|
|
875
|
+
return [{ type: 'text', text: `${v.alreadyRunning ? 'Adopted running' : 'Booted'} emulator ${v.serial} (AVD ${v.avd}), boot completed: ${v.booted}` }]
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
async execute(args) {
|
|
879
|
+
const avd = String(args.avd ?? '').trim()
|
|
880
|
+
if (avd === '') throw new Error('device_boot requires an avd name (see device_status.avds).')
|
|
881
|
+
const timeout = Math.min(Math.max(args.timeout_ms ?? 180_000, 5_000), 600_000)
|
|
882
|
+
const rows = await DeviceBuild.devices()
|
|
883
|
+
for (const row of rows.filter((item) => item.state === 'device' && item.serial.startsWith('emulator-'))) {
|
|
884
|
+
const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
|
|
885
|
+
if (name === avd) {
|
|
886
|
+
const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
|
|
887
|
+
return { serial: row.serial, avd, booted, alreadyRunning: true }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
|
|
891
|
+
const before = new Set(rows.map((item) => item.serial))
|
|
892
|
+
if (!DeviceBuild.bootEmulator(avd)) throw new Error(`Could not launch the emulator for AVD "${avd}".`)
|
|
893
|
+
const deadline = Date.now() + timeout
|
|
894
|
+
let serial
|
|
895
|
+
while (Date.now() < deadline) {
|
|
896
|
+
const now = await DeviceBuild.devices()
|
|
897
|
+
const fresh = now.find((item) => item.serial.startsWith('emulator-') && !before.has(item.serial))
|
|
898
|
+
if (fresh) { serial = fresh.serial; break }
|
|
899
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
900
|
+
}
|
|
901
|
+
if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
|
|
902
|
+
const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
|
|
903
|
+
if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
|
|
904
|
+
return { serial, avd, booted: true, alreadyRunning: false }
|
|
905
|
+
},
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function deviceShutdownTool() {
|
|
910
|
+
return defineTool({
|
|
911
|
+
name: 'device_shutdown',
|
|
912
|
+
description: 'Shut down an emulator (`adb emu kill`). Refuses physical devices — adb has no power-off verb for phones.',
|
|
913
|
+
parameters: {
|
|
914
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
915
|
+
},
|
|
916
|
+
output: {
|
|
917
|
+
schema: {
|
|
918
|
+
type: 'object',
|
|
919
|
+
additionalProperties: false,
|
|
920
|
+
properties: {
|
|
921
|
+
serial: { type: 'string', required: true },
|
|
922
|
+
shutdown: { type: 'boolean', required: true },
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
render: (_args, value) => [{ type: 'text', text: `Shut down emulator ${value?.serial}` }],
|
|
926
|
+
},
|
|
927
|
+
async execute(args) {
|
|
928
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
929
|
+
const isEmulator = serial.startsWith('emulator-') || (await DeviceBuild.avdName(serial).catch(() => undefined)) !== undefined
|
|
930
|
+
if (!isEmulator) {
|
|
931
|
+
throw new Error(`device_shutdown refuses ${serial}: it is a physical device and adb has no power-off verb for phones — use its own power button.`)
|
|
932
|
+
}
|
|
933
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
|
|
934
|
+
return { serial, shutdown: true }
|
|
935
|
+
},
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const DEVICE_ACTIONS = {
|
|
940
|
+
notifications: ['cmd', 'statusbar', 'expand-notifications'],
|
|
941
|
+
quick_settings: ['cmd', 'statusbar', 'expand-settings'],
|
|
942
|
+
collapse: ['cmd', 'statusbar', 'collapse'],
|
|
943
|
+
lock: ['input', 'keyevent', '223'],
|
|
944
|
+
wake: ['input', 'keyevent', '224'],
|
|
945
|
+
assistant: ['am', 'start', '-a', 'android.intent.action.ASSIST'],
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function deviceActionTool() {
|
|
949
|
+
return defineTool({
|
|
950
|
+
name: 'device_action',
|
|
951
|
+
description: 'Device-level actions beyond touches: open the notification shade or quick settings, collapse the shade, ' +
|
|
952
|
+
'lock or wake the screen, launch the assistant, or rotate the display (cycles 0→90→180→270 and pins auto-rotate off).',
|
|
953
|
+
parameters: {
|
|
954
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
955
|
+
action: { type: 'string', enum: [...Object.keys(DEVICE_ACTIONS), 'rotate'], description: 'Which device action to perform.' },
|
|
956
|
+
},
|
|
957
|
+
output: {
|
|
958
|
+
schema: {
|
|
959
|
+
type: 'object',
|
|
960
|
+
additionalProperties: false,
|
|
961
|
+
properties: {
|
|
962
|
+
serial: { type: 'string', required: true },
|
|
963
|
+
action: { type: 'string', required: true },
|
|
964
|
+
rotation: { type: 'integer' },
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
render: (_args, value) => [{ type: 'text', text: value?.rotation !== undefined ? `${value.action} on ${value.serial} → rotation ${value.rotation * 90}°` : `${value?.action} on ${value?.serial}` }],
|
|
968
|
+
},
|
|
969
|
+
async execute(args) {
|
|
970
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
971
|
+
const action = String(args.action ?? '')
|
|
972
|
+
if (action === 'rotate') {
|
|
973
|
+
const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
|
|
974
|
+
const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
|
|
975
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
|
|
976
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
|
|
977
|
+
return { serial, action, rotation: next }
|
|
978
|
+
}
|
|
979
|
+
const shell = DEVICE_ACTIONS[action]
|
|
980
|
+
if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
|
|
981
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
|
|
982
|
+
return { serial, action }
|
|
983
|
+
},
|
|
984
|
+
})
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function deviceAppsTool() {
|
|
988
|
+
return defineTool({
|
|
989
|
+
name: 'device_apps',
|
|
990
|
+
description: 'List installed Android packages (third-party by default; include_system=true adds platform apps). ' +
|
|
991
|
+
'Use it to find the real package name before device_launch_app — never guess one.',
|
|
992
|
+
parameters: {
|
|
993
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
994
|
+
include_system: { type: 'boolean', description: 'Include system/platform packages (default false).' },
|
|
995
|
+
},
|
|
996
|
+
output: {
|
|
997
|
+
schema: {
|
|
998
|
+
type: 'object',
|
|
999
|
+
additionalProperties: false,
|
|
1000
|
+
properties: {
|
|
1001
|
+
serial: { type: 'string', required: true },
|
|
1002
|
+
count: { type: 'integer', required: true },
|
|
1003
|
+
packages: { type: 'array', required: true, items: { type: 'string' } },
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
render: (_args, value) => {
|
|
1007
|
+
const v = value ?? { serial: '', count: 0, packages: [] }
|
|
1008
|
+
const shown = v.packages.slice(0, 60).join('\n ')
|
|
1009
|
+
return [{ type: 'text', text: `${v.serial}: ${v.count} packages\n ${shown}${v.count > 60 ? `\n … and ${v.count - 60} more` : ''}` }]
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
async execute(args) {
|
|
1013
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1014
|
+
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
|
|
1015
|
+
const packages = output
|
|
1016
|
+
.split(/\r?\n/)
|
|
1017
|
+
.map((line) => line.trim())
|
|
1018
|
+
.filter((line) => line.startsWith('package:'))
|
|
1019
|
+
.map((line) => line.slice('package:'.length).trim())
|
|
1020
|
+
return { serial, count: packages.length, packages: packages.slice(0, 200) }
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function deviceLaunchAppTool() {
|
|
1026
|
+
return defineTool({
|
|
1027
|
+
name: 'device_launch_app',
|
|
1028
|
+
description: 'Launch an installed app by package name (or a unique substring of it). relaunch=true force-stops it first ' +
|
|
1029
|
+
'for a cold start. Resolves the exact package via `pm list packages` so a wrong guess fails loudly instead of ' +
|
|
1030
|
+
'opening the wrong app.',
|
|
1031
|
+
parameters: {
|
|
1032
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1033
|
+
package: { type: 'string', description: 'Package name or a unique substring of it.' },
|
|
1034
|
+
relaunch: { type: 'boolean', description: 'Force-stop the app first (cold start).' },
|
|
1035
|
+
},
|
|
1036
|
+
output: {
|
|
1037
|
+
schema: {
|
|
1038
|
+
type: 'object',
|
|
1039
|
+
additionalProperties: false,
|
|
1040
|
+
properties: {
|
|
1041
|
+
serial: { type: 'string', required: true },
|
|
1042
|
+
package: { type: 'string', required: true },
|
|
1043
|
+
launched: { type: 'boolean', required: true },
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
render: (_args, value) => [{ type: 'text', text: `Launched ${value?.package} on ${value?.serial}` }],
|
|
1047
|
+
},
|
|
1048
|
+
async execute(args) {
|
|
1049
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1050
|
+
let pkg = String(args.package ?? '').trim()
|
|
1051
|
+
if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
|
|
1052
|
+
const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
|
|
1053
|
+
const all = listOut
|
|
1054
|
+
.split(/\r?\n/)
|
|
1055
|
+
.map((line) => line.trim())
|
|
1056
|
+
.filter((line) => line.startsWith('package:'))
|
|
1057
|
+
.map((line) => line.slice('package:'.length).trim())
|
|
1058
|
+
if (!all.includes(pkg)) {
|
|
1059
|
+
const matches = all.filter((item) => item.toLowerCase().includes(pkg.toLowerCase()))
|
|
1060
|
+
if (matches.length === 0) throw new Error(`No installed package matches "${pkg}". Run device_apps to list them.`)
|
|
1061
|
+
if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
|
|
1062
|
+
pkg = matches[0]
|
|
1063
|
+
}
|
|
1064
|
+
if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
|
|
1065
|
+
const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
|
|
1066
|
+
if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
|
|
1067
|
+
return { serial, package: pkg, launched: true }
|
|
1068
|
+
},
|
|
1069
|
+
})
|
|
1070
|
+
}
|
|
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
|
+
|
|
604
1123
|
function deviceScreenTool(engine) {
|
|
605
1124
|
return defineTool({
|
|
606
1125
|
name: 'device_screen',
|
|
@@ -715,7 +1234,9 @@ function deviceScreenTool(engine) {
|
|
|
715
1234
|
|
|
716
1235
|
async function captureScreenSize(serial) {
|
|
717
1236
|
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
|
|
718
|
-
|
|
1237
|
+
// An `wm size` override wins over the physical panel — the input space is the override.
|
|
1238
|
+
const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
|
|
1239
|
+
const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
|
|
719
1240
|
return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
|
|
720
1241
|
}
|
|
721
1242
|
|
|
@@ -1042,8 +1563,17 @@ function guidance() {
|
|
|
1042
1563
|
' text, and pixel bounds. Narrow with filter/max_depth; on a textless surface (WebView, Compose, canvas) use device_screen.',
|
|
1043
1564
|
'- device_tap_element: tap a control by resource_id or text/content-desc, with one-call verification via',
|
|
1044
1565
|
' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
|
|
1566
|
+
'- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
|
|
1567
|
+
' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
|
|
1045
1568
|
'- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
|
|
1046
|
-
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is
|
|
1569
|
+
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_tree → device_tap_element, falling back to',
|
|
1570
|
+
' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
|
|
1571
|
+
' (CJK, emoji) goes through the ADBKeyboard IME when installed, and is refused with the install hint otherwise.',
|
|
1572
|
+
'- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
|
|
1573
|
+
'- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
|
|
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.',
|
|
1047
1577
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
1048
1578
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
1049
1579
|
'',
|
|
@@ -1072,8 +1602,11 @@ export function apply(ctx, config) {
|
|
|
1072
1602
|
})
|
|
1073
1603
|
|
|
1074
1604
|
const engine = new DevicePreviewEngine()
|
|
1605
|
+
const streamHost = new AndroidStreamHost()
|
|
1606
|
+
const streamAccess = new StreamAccess.StreamAccessController()
|
|
1075
1607
|
const handle = {
|
|
1076
1608
|
engine,
|
|
1609
|
+
stream: streamHost,
|
|
1077
1610
|
status: () => ({
|
|
1078
1611
|
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
1079
1612
|
servers: [...engine.servers.keys()],
|
|
@@ -1084,7 +1617,7 @@ export function apply(ctx, config) {
|
|
|
1084
1617
|
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
1085
1618
|
else ctx.mobilecode = handle
|
|
1086
1619
|
|
|
1087
|
-
const routes = makeRoutes(engine, config)
|
|
1620
|
+
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
|
|
1088
1621
|
let disposeRoutes
|
|
1089
1622
|
let disposeTools
|
|
1090
1623
|
let disposeSection
|
|
@@ -1095,6 +1628,7 @@ export function apply(ctx, config) {
|
|
|
1095
1628
|
if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
|
|
1096
1629
|
if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
|
|
1097
1630
|
if (!value.enabled) return
|
|
1631
|
+
streamHost.startKeepAlive()
|
|
1098
1632
|
if (value.announceToAgent) {
|
|
1099
1633
|
disposeSection = ctx.systemPrompt.section({
|
|
1100
1634
|
name: 'plugin:dsh-mobilecode',
|
|
@@ -1113,6 +1647,13 @@ export function apply(ctx, config) {
|
|
|
1113
1647
|
deviceScreenTool(engine),
|
|
1114
1648
|
deviceUiTreeTool(),
|
|
1115
1649
|
deviceTapElementTool(),
|
|
1650
|
+
deviceWaitForTool(),
|
|
1651
|
+
deviceBootTool(),
|
|
1652
|
+
deviceShutdownTool(),
|
|
1653
|
+
deviceActionTool(),
|
|
1654
|
+
deviceAppsTool(),
|
|
1655
|
+
deviceLaunchAppTool(),
|
|
1656
|
+
deviceStreamTool(streamHost, streamAccess),
|
|
1116
1657
|
deviceLogTool(engine),
|
|
1117
1658
|
deviceStatusTool(engine),
|
|
1118
1659
|
deviceInputTool(),
|
|
@@ -1123,6 +1664,7 @@ export function apply(ctx, config) {
|
|
|
1123
1664
|
|
|
1124
1665
|
ctx.effect(() => () => {
|
|
1125
1666
|
disposeSkill()
|
|
1667
|
+
void streamHost.dispose()
|
|
1126
1668
|
void engine.dispose()
|
|
1127
1669
|
}, 'dsh-mobilecode: engine')
|
|
1128
1670
|
|