@windypro-rourou/dsh-logcat 0.6.6 → 0.7.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.en.md +2 -2
- package/README.md +10 -3
- package/lib/client.js +453 -40
- package/lib/index.js +590 -0
- package/lib/injector-jar.js +6 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { homedir } from 'node:os'
|
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import { createInterface } from 'node:readline'
|
|
23
23
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
24
|
+
import { INJECTOR_JAR_BASE64 } from './injector-jar.js'
|
|
24
25
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
25
26
|
|
|
26
27
|
/** This package's manifest (name + version), read from the installed location. */
|
|
@@ -358,6 +359,392 @@ class DeviceStream {
|
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
361
|
|
|
362
|
+
/** Incremental Annex-B H.264 parser: groups NAL units into access units and emits AVCC frames. */
|
|
363
|
+
class H264Framer {
|
|
364
|
+
constructor(onFrame, onInit) {
|
|
365
|
+
this.buf = Buffer.alloc(0)
|
|
366
|
+
this.parts = []
|
|
367
|
+
this.hasVcl = false
|
|
368
|
+
this.hasIdr = false
|
|
369
|
+
this.sps = null
|
|
370
|
+
this.pps = null
|
|
371
|
+
this.initSent = false
|
|
372
|
+
this.onFrame = onFrame
|
|
373
|
+
this.onInit = onInit
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Exponential-Golomb ue(v) reading, starting at byte `fromByte` of `nal`. */
|
|
377
|
+
readUe(nal, fromByte) {
|
|
378
|
+
const bitAt = (p) => ((nal[p >> 3] >> (7 - (p & 7))) & 1)
|
|
379
|
+
const total = nal.length * 8
|
|
380
|
+
let pos = fromByte * 8
|
|
381
|
+
let zeros = 0
|
|
382
|
+
while (pos < total && bitAt(pos) === 0) { zeros += 1; pos += 1 }
|
|
383
|
+
if (pos >= total) return -1
|
|
384
|
+
pos += 1
|
|
385
|
+
let value = 0
|
|
386
|
+
for (let k = 0; k < zeros && pos < total; k++) { value = (value << 1) | bitAt(pos); pos += 1 }
|
|
387
|
+
return (1 << zeros) - 1 + value
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Feed raw bytes; complete access units are emitted as AVCC frames. */
|
|
391
|
+
push(chunk) {
|
|
392
|
+
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk])
|
|
393
|
+
if (this.buf.length > 8 * 1024 * 1024) { this.buf = this.buf.subarray(this.buf.length - 1024 * 1024) } // runaway guard
|
|
394
|
+
const nals = []
|
|
395
|
+
let lastStart = -1
|
|
396
|
+
let i = 0
|
|
397
|
+
while (i + 3 <= this.buf.length) {
|
|
398
|
+
const is4 = i + 4 <= this.buf.length && this.buf[i] === 0 && this.buf[i + 1] === 0 && this.buf[i + 2] === 0 && this.buf[i + 3] === 1
|
|
399
|
+
const is3 = !is4 && this.buf[i] === 0 && this.buf[i + 1] === 0 && this.buf[i + 2] === 1
|
|
400
|
+
if (is4 || is3) {
|
|
401
|
+
if (lastStart >= 0) nals.push([lastStart, i])
|
|
402
|
+
lastStart = i
|
|
403
|
+
i += is4 ? 4 : 3
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
i += 1
|
|
407
|
+
}
|
|
408
|
+
if (lastStart < 0) return
|
|
409
|
+
for (const [s, e] of nals) this.handleNal(this.buf.subarray(s, e))
|
|
410
|
+
// Keep the trailing NAL (possibly incomplete) for the next chunk.
|
|
411
|
+
this.buf = this.buf.subarray(lastStart)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Classify one NAL (with its start code) and fold it into the current access unit. */
|
|
415
|
+
handleNal(raw) {
|
|
416
|
+
let off = 0
|
|
417
|
+
if (raw.length >= 4 && raw[0] === 0 && raw[1] === 0 && raw[2] === 0 && raw[3] === 1) off = 4
|
|
418
|
+
else if (raw.length >= 3 && raw[0] === 0 && raw[1] === 0 && raw[2] === 1) off = 3
|
|
419
|
+
const nal = raw.subarray(off)
|
|
420
|
+
if (nal.length === 0) return
|
|
421
|
+
const type = nal[0] & 0x1f
|
|
422
|
+
if (type === 7) {
|
|
423
|
+
if (this.hasVcl) this.flush()
|
|
424
|
+
this.sps = Buffer.from(nal)
|
|
425
|
+
this.sendInitIfReady()
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (type === 8) {
|
|
429
|
+
if (this.hasVcl) this.flush()
|
|
430
|
+
this.pps = Buffer.from(nal)
|
|
431
|
+
this.sendInitIfReady()
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
if (type === 9 || type === 6) {
|
|
435
|
+
// AUD / SEI: a new access unit begins; SEI is dropped (not needed for decoding).
|
|
436
|
+
if (this.hasVcl) this.flush()
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
if (type === 1 || type === 5) {
|
|
440
|
+
const firstMb = this.readUe(nal, 1)
|
|
441
|
+
if (firstMb === 0 && this.hasVcl) this.flush()
|
|
442
|
+
this.parts.push(nal)
|
|
443
|
+
this.hasVcl = true
|
|
444
|
+
if (type === 5) this.hasIdr = true
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
if (this.hasVcl) this.parts.push(nal)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Emit the buffered access unit as one AVCC frame (4-byte length prefixes). */
|
|
451
|
+
flush() {
|
|
452
|
+
if (!this.hasVcl || this.parts.length === 0) {
|
|
453
|
+
this.parts = []
|
|
454
|
+
this.hasVcl = false
|
|
455
|
+
this.hasIdr = false
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
let size = 0
|
|
459
|
+
for (const p of this.parts) size += 4 + p.length
|
|
460
|
+
const out = Buffer.allocUnsafe(size)
|
|
461
|
+
let o = 0
|
|
462
|
+
for (const p of this.parts) {
|
|
463
|
+
out.writeUInt32BE(p.length, o)
|
|
464
|
+
p.copy(out, o + 4)
|
|
465
|
+
o += 4 + p.length
|
|
466
|
+
}
|
|
467
|
+
const key = this.hasIdr
|
|
468
|
+
this.parts = []
|
|
469
|
+
this.hasVcl = false
|
|
470
|
+
this.hasIdr = false
|
|
471
|
+
this.onFrame(out, key)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Build the avcC description + codec string once SPS and PPS are known. */
|
|
475
|
+
buildInit() {
|
|
476
|
+
if (this.sps === null || this.pps === null) return null
|
|
477
|
+
const sps = this.sps
|
|
478
|
+
const pps = this.pps
|
|
479
|
+
const avcC = Buffer.alloc(11 + sps.length + pps.length)
|
|
480
|
+
let o = 0
|
|
481
|
+
avcC[o++] = 1
|
|
482
|
+
avcC[o++] = sps[1]
|
|
483
|
+
avcC[o++] = sps[2]
|
|
484
|
+
avcC[o++] = sps[3]
|
|
485
|
+
avcC[o++] = 0xfc | 3 // lengthSizeMinusOne
|
|
486
|
+
avcC[o++] = 0xe0 | 1 // numOfSequenceParameterSets
|
|
487
|
+
avcC.writeUInt16BE(sps.length, o); o += 2
|
|
488
|
+
sps.copy(avcC, o); o += sps.length
|
|
489
|
+
avcC[o++] = 1 // numOfPictureParameterSets
|
|
490
|
+
avcC.writeUInt16BE(pps.length, o); o += 2
|
|
491
|
+
pps.copy(avcC, o); o += pps.length
|
|
492
|
+
return { codec: 'avc1.' + sps.subarray(1, 4).toString('hex'), avcC: avcC.subarray(0, o) }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
sendInitIfReady() {
|
|
496
|
+
if (this.initSent) return
|
|
497
|
+
const init = this.buildInit()
|
|
498
|
+
if (init === null) return
|
|
499
|
+
this.initSent = true
|
|
500
|
+
this.onInit(init)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** One H.264 screen stream (screenrecord child) per device. */
|
|
505
|
+
/** Where the embedded injector dex is deployed on the device. */
|
|
506
|
+
const REMOTE_JAR = '/data/local/tmp/dsh-injector.jar'
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Map a stock `input ...` shell command onto the injector's protocol.
|
|
510
|
+
* Returns null for anything the injector cannot do (notably `input text`).
|
|
511
|
+
*/
|
|
512
|
+
function mapInputCommand(command) {
|
|
513
|
+
const match = /^input\s+(tap|swipe|keyevent)\s+([\d.\s]+)$/.exec(String(command).trim())
|
|
514
|
+
if (match === null) return null
|
|
515
|
+
const args = match[2].trim().replace(/\s+/g, ' ')
|
|
516
|
+
if (args === '') return null
|
|
517
|
+
return (match[1] === 'keyevent' ? 'key ' : match[1] + ' ') + args
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Long-lived input injector.
|
|
522
|
+
*
|
|
523
|
+
* Every call to the stock `input` command boots a fresh app_process (a Java VM): measured
|
|
524
|
+
* ~480ms on the reference device, which is exactly the lag the panel's touch controls had.
|
|
525
|
+
* This keeps one Injector process alive per device and streams gestures into its stdin, so a
|
|
526
|
+
* tap costs a few milliseconds instead. Any failure falls back to the stock command.
|
|
527
|
+
*/
|
|
528
|
+
class InputChannel {
|
|
529
|
+
constructor(adb, serial) {
|
|
530
|
+
this.adb = adb
|
|
531
|
+
this.serial = serial
|
|
532
|
+
this.child = null
|
|
533
|
+
this.ready = false
|
|
534
|
+
this.starting = null
|
|
535
|
+
this.pending = null
|
|
536
|
+
this.buffer = ''
|
|
537
|
+
this.deployed = false
|
|
538
|
+
this.onReady = null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Start (and deploy, once) the injector. Resolves true when it answered READY. */
|
|
542
|
+
start() {
|
|
543
|
+
if (this.ready && this.child !== null) return Promise.resolve(true)
|
|
544
|
+
if (this.starting !== null) return this.starting
|
|
545
|
+
this.starting = this.launch().finally(() => { this.starting = null })
|
|
546
|
+
return this.starting
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async launch() {
|
|
550
|
+
if (!this.deployed) {
|
|
551
|
+
// Written over a plain shell command: `adb push` speaks the sync protocol, which drops
|
|
552
|
+
// out on flaky USB links, whereas a shell write is a single round-trip.
|
|
553
|
+
const write = await runAdbFull(this.adb, ['-s', this.serial, 'shell', `echo ${INJECTOR_JAR_BASE64} | base64 -d > ${REMOTE_JAR}`], 25000)
|
|
554
|
+
if (write.ok !== true) return false
|
|
555
|
+
this.deployed = true
|
|
556
|
+
}
|
|
557
|
+
let child
|
|
558
|
+
try {
|
|
559
|
+
child = spawn(this.adb, ['-s', this.serial, 'shell', `CLASSPATH=${REMOTE_JAR} app_process /system/bin dsh.Injector`], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
560
|
+
} catch { return false }
|
|
561
|
+
this.child = child
|
|
562
|
+
this.buffer = ''
|
|
563
|
+
child.stdout.on('data', (chunk) => this.onData(String(chunk)))
|
|
564
|
+
child.stderr.on('data', () => { /* diagnostics only */ })
|
|
565
|
+
const dead = () => { this.child = null; this.ready = false; this.deployed = false }
|
|
566
|
+
child.on('exit', dead)
|
|
567
|
+
child.on('error', dead)
|
|
568
|
+
// READY means the VM is up and InputManager resolved; anything slower counts as failure.
|
|
569
|
+
const ok = await new Promise((resolve) => {
|
|
570
|
+
const timer = setTimeout(() => resolve(false), 8000)
|
|
571
|
+
this.onReady = () => { clearTimeout(timer); resolve(true) }
|
|
572
|
+
child.on('exit', () => { clearTimeout(timer); resolve(false) })
|
|
573
|
+
})
|
|
574
|
+
if (!ok) {
|
|
575
|
+
try { child.kill() } catch { /* gone */ }
|
|
576
|
+
this.child = null
|
|
577
|
+
return false
|
|
578
|
+
}
|
|
579
|
+
this.ready = true
|
|
580
|
+
return true
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
onData(text) {
|
|
584
|
+
this.buffer += text
|
|
585
|
+
let index
|
|
586
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
587
|
+
const line = this.buffer.slice(0, index).trim()
|
|
588
|
+
this.buffer = this.buffer.slice(index + 1)
|
|
589
|
+
if (line === '') continue
|
|
590
|
+
if (line === 'READY') {
|
|
591
|
+
const fire = this.onReady
|
|
592
|
+
this.onReady = null
|
|
593
|
+
if (fire !== null) fire()
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
const pending = this.pending
|
|
597
|
+
if (pending !== null) {
|
|
598
|
+
this.pending = null
|
|
599
|
+
clearTimeout(pending.timer)
|
|
600
|
+
pending.resolve(line)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Send one gesture; resolves { error, ms } — or null when the injector is unusable. */
|
|
606
|
+
send(line) {
|
|
607
|
+
const child = this.child
|
|
608
|
+
if (!this.ready || child === null) return Promise.resolve(null)
|
|
609
|
+
return new Promise((resolve) => {
|
|
610
|
+
const timer = setTimeout(() => { this.pending = null; resolve(null) }, 4000)
|
|
611
|
+
this.pending = {
|
|
612
|
+
timer,
|
|
613
|
+
resolve: (reply) => {
|
|
614
|
+
const error = /^ERR/.test(reply) ? reply.slice(4).trim() : ''
|
|
615
|
+
const ms = Number((/^OK\s+(\d+)/.exec(reply) ?? [])[1] ?? 0)
|
|
616
|
+
resolve({ error, ms })
|
|
617
|
+
},
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
child.stdin.write(line + '\n')
|
|
621
|
+
} catch {
|
|
622
|
+
clearTimeout(timer)
|
|
623
|
+
this.pending = null
|
|
624
|
+
resolve(null)
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
dispose() {
|
|
630
|
+
this.ready = false
|
|
631
|
+
if (this.child !== null) {
|
|
632
|
+
try { this.child.kill() } catch { /* gone */ }
|
|
633
|
+
this.child = null
|
|
634
|
+
}
|
|
635
|
+
if (this.pending !== null) {
|
|
636
|
+
clearTimeout(this.pending.timer)
|
|
637
|
+
this.pending.resolve('')
|
|
638
|
+
this.pending = null
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
class VideoStream {
|
|
644
|
+
constructor(adb, serial, opts, handlers) {
|
|
645
|
+
this.adb = adb
|
|
646
|
+
this.serial = serial
|
|
647
|
+
this.opts = opts // { size, bitRate }
|
|
648
|
+
this.onFrame = handlers.onFrame
|
|
649
|
+
this.onInit = handlers.onInit
|
|
650
|
+
this.onState = handlers.onState
|
|
651
|
+
this.child = null
|
|
652
|
+
this.stopped = false
|
|
653
|
+
this.restartTimer = null
|
|
654
|
+
this.framer = null
|
|
655
|
+
this.frames = 0
|
|
656
|
+
this.bytes = 0
|
|
657
|
+
this.startedAt = 0
|
|
658
|
+
this.lastError = ''
|
|
659
|
+
this.lastFrameAt = 0
|
|
660
|
+
this.lastIdle = false
|
|
661
|
+
this.watchTimer = null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
start() {
|
|
665
|
+
if (this.stopped || this.child !== null) return
|
|
666
|
+
const args = ['-s', this.serial, 'exec-out', 'screenrecord',
|
|
667
|
+
'--output-format=h264', '--size', this.opts.size,
|
|
668
|
+
'--bit-rate', String(this.opts.bitRate), '--time-limit', '179', '-']
|
|
669
|
+
let child
|
|
670
|
+
try {
|
|
671
|
+
child = spawn(this.adb, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
672
|
+
} catch (error) {
|
|
673
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
674
|
+
this.scheduleRestart(3000)
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
this.child = child
|
|
678
|
+
this.startedAt = Date.now()
|
|
679
|
+
this.frames = 0
|
|
680
|
+
this.bytes = 0
|
|
681
|
+
this.framer = new H264Framer(
|
|
682
|
+
(frame, key) => { this.frames += 1; this.lastFrameAt = Date.now(); this.onFrame(frame, key) },
|
|
683
|
+
(init) => this.onInit(init),
|
|
684
|
+
)
|
|
685
|
+
// Watch for an idle stream (device asleep / static screen) so the panel can hint the user.
|
|
686
|
+
if (this.watchTimer !== null) clearInterval(this.watchTimer)
|
|
687
|
+
this.watchTimer = setInterval(() => {
|
|
688
|
+
const idle = Date.now() - (this.lastFrameAt || this.startedAt) > 2500
|
|
689
|
+
if (idle !== this.lastIdle) {
|
|
690
|
+
this.lastIdle = idle
|
|
691
|
+
this.onState({ running: true, idle })
|
|
692
|
+
}
|
|
693
|
+
}, 2500)
|
|
694
|
+
// screenrecord produces frames only when the screen changes: the first probe
|
|
695
|
+
// also flips the idle flag off as soon as a frame arrives.
|
|
696
|
+
let stderrText = ''
|
|
697
|
+
child.stderr.on('data', (c) => {
|
|
698
|
+
stderrText = (stderrText + String(c)).slice(-600)
|
|
699
|
+
this.lastError = stderrText.trim().slice(-300)
|
|
700
|
+
})
|
|
701
|
+
child.stdout.on('data', (chunk) => {
|
|
702
|
+
this.bytes += chunk.length
|
|
703
|
+
if (this.framer !== null) this.framer.push(chunk)
|
|
704
|
+
})
|
|
705
|
+
const done = () => {
|
|
706
|
+
this.child = null
|
|
707
|
+
if (this.stopped) return
|
|
708
|
+
// --time-limit reached (or the encoder exited): restart promptly, the stream continues.
|
|
709
|
+
this.onState({ running: true, restarted: true, error: this.lastError })
|
|
710
|
+
this.scheduleRestart(120)
|
|
711
|
+
}
|
|
712
|
+
child.on('exit', done)
|
|
713
|
+
child.on('error', done)
|
|
714
|
+
this.onState({ running: true, restarted: false })
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
scheduleRestart(ms) {
|
|
718
|
+
if (this.stopped) return
|
|
719
|
+
if (this.restartTimer !== null) clearTimeout(this.restartTimer)
|
|
720
|
+
this.restartTimer = setTimeout(() => { this.restartTimer = null; this.start() }, ms)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
stop() {
|
|
724
|
+
this.stopped = true
|
|
725
|
+
if (this.restartTimer !== null) clearTimeout(this.restartTimer)
|
|
726
|
+
this.restartTimer = null
|
|
727
|
+
if (this.watchTimer !== null) { clearInterval(this.watchTimer); this.watchTimer = null }
|
|
728
|
+
if (this.child !== null) {
|
|
729
|
+
try { this.child.kill() } catch { /* gone */ }
|
|
730
|
+
this.child = null
|
|
731
|
+
}
|
|
732
|
+
this.onState({ running: false })
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
stats() {
|
|
736
|
+
const sec = (Date.now() - this.startedAt) / 1000
|
|
737
|
+
return {
|
|
738
|
+
size: this.opts.size,
|
|
739
|
+
bitRate: this.opts.bitRate,
|
|
740
|
+
fps: sec > 0 ? Math.round((this.frames / sec) * 10) / 10 : 0,
|
|
741
|
+
mbps: sec > 0 ? Math.round((this.bytes * 8 / sec / 1e6) * 100) / 100 : 0,
|
|
742
|
+
idle: Date.now() - (this.lastFrameAt || this.startedAt) > 2500,
|
|
743
|
+
error: this.lastError || undefined,
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
361
748
|
/** The adb engine: device poll, per-device ring buffers, ws fan-out. */
|
|
362
749
|
export class AdbEngine {
|
|
363
750
|
constructor() {
|
|
@@ -378,6 +765,9 @@ export class AdbEngine {
|
|
|
378
765
|
this.eventsStreams = new Map() // serial -> DeviceStream (-b events)
|
|
379
766
|
this.lastCrash = new Map() // serial -> ms timestamp (snapshot throttle)
|
|
380
767
|
this.screenLoops = new Map() // serial -> { timer, busy } (1fps screen stream)
|
|
768
|
+
this.videoStreams = new Map() // serial -> VideoStream (H.264 screenrecord stream)
|
|
769
|
+
this.displays = new Map() // serial -> { width, height, rotation, at } (oriented display)
|
|
770
|
+
this.inputChannels = new Map() // serial -> InputChannel (long-lived gesture injector)
|
|
381
771
|
}
|
|
382
772
|
|
|
383
773
|
/** Serial -> filesystem-safe directory name. */
|
|
@@ -432,6 +822,10 @@ export class AdbEngine {
|
|
|
432
822
|
this.eventsStreams.clear()
|
|
433
823
|
for (const loop of this.screenLoops.values()) clearInterval(loop.timer)
|
|
434
824
|
this.screenLoops.clear()
|
|
825
|
+
for (const stream of this.videoStreams.values()) stream.stop()
|
|
826
|
+
this.videoStreams.clear()
|
|
827
|
+
for (const channel of this.inputChannels.values()) channel.dispose()
|
|
828
|
+
this.inputChannels.clear()
|
|
435
829
|
for (const ws of this.clients) {
|
|
436
830
|
try { ws.close(1001, 'plugin disposed') } catch { /* closed */ }
|
|
437
831
|
}
|
|
@@ -447,6 +841,8 @@ export class AdbEngine {
|
|
|
447
841
|
const next = parseDevices(output)
|
|
448
842
|
const changed = this.syncDevices(next)
|
|
449
843
|
if (changed) this.broadcast({ type: 'devices', devices: this.deviceList() })
|
|
844
|
+
if (this.videoStreams.size > 0) void this.followRotation()
|
|
845
|
+
void this.warmInput()
|
|
450
846
|
} finally {
|
|
451
847
|
this.polling = false
|
|
452
848
|
}
|
|
@@ -476,6 +872,11 @@ export class AdbEngine {
|
|
|
476
872
|
clearInterval(sloop.timer)
|
|
477
873
|
this.screenLoops.delete(serial)
|
|
478
874
|
}
|
|
875
|
+
const vstream = this.videoStreams.get(serial)
|
|
876
|
+
if (vstream !== undefined) {
|
|
877
|
+
vstream.stop()
|
|
878
|
+
this.videoStreams.delete(serial)
|
|
879
|
+
}
|
|
479
880
|
changed = true
|
|
480
881
|
}
|
|
481
882
|
}
|
|
@@ -507,6 +908,11 @@ export class AdbEngine {
|
|
|
507
908
|
clearInterval(sloop.timer)
|
|
508
909
|
this.screenLoops.delete(serial)
|
|
509
910
|
}
|
|
911
|
+
const vstream = this.videoStreams.get(serial)
|
|
912
|
+
if (vstream !== undefined) {
|
|
913
|
+
vstream.stop()
|
|
914
|
+
this.videoStreams.delete(serial)
|
|
915
|
+
}
|
|
510
916
|
this.broadcast({ type: 'device-state', serial, state: info.state })
|
|
511
917
|
}
|
|
512
918
|
}
|
|
@@ -689,6 +1095,8 @@ export class AdbEngine {
|
|
|
689
1095
|
|
|
690
1096
|
/** Start / stop the screen-stream loop for a device (panel live view). */
|
|
691
1097
|
setScreen(serial, on) {
|
|
1098
|
+
if (on) void this.displayState(serial)
|
|
1099
|
+
if (on) void this.ensureInput(serial)
|
|
692
1100
|
const existing = this.screenLoops.get(serial)
|
|
693
1101
|
if (on) {
|
|
694
1102
|
if (existing === undefined && this.adb !== null && this.devices.get(serial)?.state === 'device') {
|
|
@@ -728,6 +1136,167 @@ export class AdbEngine {
|
|
|
728
1136
|
} catch { /* frame dropped */ }
|
|
729
1137
|
}
|
|
730
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* Start / stop the H.264 screen stream (screenrecord) for a device — the
|
|
1141
|
+
* 30fps path used by the panel's live view. `opts.size` / `opts.bitRate`
|
|
1142
|
+
* restart the encoder when they change.
|
|
1143
|
+
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Current *oriented* display geometry of a device. screenrecord always encodes against the
|
|
1146
|
+
* panel's natural orientation, so a landscape app ends up letterboxed inside a portrait
|
|
1147
|
+
* frame (measured: 80% black bars) and the encoded pixels no longer match the touch
|
|
1148
|
+
* coordinate space. Callers use this to swap the requested stream size instead.
|
|
1149
|
+
* Cheap: `grep -m1` makes the device-side dumpsys exit early.
|
|
1150
|
+
*/
|
|
1151
|
+
async displayState(serial, maxAgeMs = 1200) {
|
|
1152
|
+
const cached = this.displays.get(serial)
|
|
1153
|
+
const now = Date.now()
|
|
1154
|
+
if (cached !== undefined && now - cached.at < maxAgeMs) return cached
|
|
1155
|
+
let next = cached ?? null
|
|
1156
|
+
try {
|
|
1157
|
+
const out = await runAdb(this.adb, ['-s', serial, 'shell', 'dumpsys display | grep -m1 mOverrideDisplayInfo'], 6000)
|
|
1158
|
+
if (typeof out === 'string' && out !== '') {
|
|
1159
|
+
const size = /real (\d+) x (\d+)/.exec(out)
|
|
1160
|
+
const rot = /rotation (\d+)/.exec(out)
|
|
1161
|
+
if (size !== null) {
|
|
1162
|
+
next = { width: Number(size[1]), height: Number(size[2]), rotation: rot === null ? 0 : Number(rot[1]), at: now }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
} catch { /* keep the last known geometry */ }
|
|
1166
|
+
if (next !== null) this.displays.set(serial, next)
|
|
1167
|
+
return next
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Swap a stream size when the device's current rotation contradicts it (the letterbox case). */
|
|
1171
|
+
async orientedSize(serial, size) {
|
|
1172
|
+
const [w, h] = String(size).split('x').map(Number)
|
|
1173
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return size
|
|
1174
|
+
const disp = await this.displayState(serial)
|
|
1175
|
+
if (disp === null) return size
|
|
1176
|
+
if ((w > h) === (disp.width > disp.height)) return size
|
|
1177
|
+
return `${h}x${w}`
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Latest known oriented geometry per device (exposed to the panel for touch mapping). */
|
|
1181
|
+
displaySnapshot() {
|
|
1182
|
+
const out = {}
|
|
1183
|
+
for (const [serial, d] of this.displays) out[serial] = { width: d.width, height: d.height, rotation: d.rotation }
|
|
1184
|
+
return out
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Keep the gesture injector warm while a screen stream is live. A failed start (device
|
|
1189
|
+
* briefly offline, adb hiccup) used to be permanent because nothing retried until the next
|
|
1190
|
+
* user gesture; this repairs the channel in the background.
|
|
1191
|
+
*/
|
|
1192
|
+
async warmInput() {
|
|
1193
|
+
const now = Date.now()
|
|
1194
|
+
if (now - (this.lastWarmAt ?? 0) < 8000) return
|
|
1195
|
+
this.lastWarmAt = now
|
|
1196
|
+
const serials = new Set([...this.streams.keys(), ...this.videoStreams.keys()])
|
|
1197
|
+
for (const serial of serials) {
|
|
1198
|
+
const channel = this.inputChannels.get(serial)
|
|
1199
|
+
if (channel !== undefined && channel.ready) continue
|
|
1200
|
+
await this.ensureInput(serial)
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** screenrecord cannot rotate mid-stream: rebuild the encoder when the display turns. */
|
|
1205
|
+
async followRotation() {
|
|
1206
|
+
for (const [serial, stream] of [...this.videoStreams]) {
|
|
1207
|
+
const baseSize = stream.opts.baseSize
|
|
1208
|
+
if (typeof baseSize !== 'string') continue
|
|
1209
|
+
const want = await this.orientedSize(serial, baseSize)
|
|
1210
|
+
if (want === stream.opts.size) continue
|
|
1211
|
+
const bitRate = stream.opts.bitRate
|
|
1212
|
+
stream.stop()
|
|
1213
|
+
this.videoStreams.delete(serial)
|
|
1214
|
+
await this.setVideo(serial, true, { size: baseSize, bitRate })
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Deploy/start (once) and reuse the long-lived injector for a device. */
|
|
1219
|
+
async ensureInput(serial) {
|
|
1220
|
+
if (this.adb === null || this.devices.get(serial)?.state !== 'device') return null
|
|
1221
|
+
let channel = this.inputChannels.get(serial)
|
|
1222
|
+
if (channel === undefined) {
|
|
1223
|
+
channel = new InputChannel(this.adb, serial)
|
|
1224
|
+
this.inputChannels.set(serial, channel)
|
|
1225
|
+
}
|
|
1226
|
+
const ready = await channel.start()
|
|
1227
|
+
if (!ready) {
|
|
1228
|
+
channel.dispose()
|
|
1229
|
+
this.inputChannels.delete(serial)
|
|
1230
|
+
return null
|
|
1231
|
+
}
|
|
1232
|
+
return channel
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Fast path for `input tap|swipe|keyevent` through the long-lived injector. Returns null
|
|
1237
|
+
* when the command is not injectable or the helper is unavailable — the caller then falls
|
|
1238
|
+
* back to the stock shell command.
|
|
1239
|
+
*/
|
|
1240
|
+
async tryInject(serial, command) {
|
|
1241
|
+
const mapped = mapInputCommand(command)
|
|
1242
|
+
if (mapped === null) return null
|
|
1243
|
+
const channel = await this.ensureInput(serial)
|
|
1244
|
+
if (channel === null) return null
|
|
1245
|
+
const result = await channel.send(mapped)
|
|
1246
|
+
if (result === null || result.error !== '') return null
|
|
1247
|
+
return result
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Devices whose gesture injector is live (diagnostics + panel hint). */
|
|
1251
|
+
inputReady() {
|
|
1252
|
+
const out = []
|
|
1253
|
+
for (const [serial, channel] of this.inputChannels) if (channel.ready) out.push(serial)
|
|
1254
|
+
return out
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async setVideo(serial, on, opts = {}) {
|
|
1258
|
+
const existing = this.videoStreams.get(serial)
|
|
1259
|
+
if (!on) {
|
|
1260
|
+
if (existing !== undefined) {
|
|
1261
|
+
existing.stop()
|
|
1262
|
+
this.videoStreams.delete(serial)
|
|
1263
|
+
}
|
|
1264
|
+
this.broadcast({ type: 'video-state', serial, running: false })
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1267
|
+
// Warm the gesture injector while the stream starts, so the first tap is already fast.
|
|
1268
|
+
void this.ensureInput(serial)
|
|
1269
|
+
const baseSize = typeof opts.size === 'string' && /^\d{2,4}x\d{2,4}$/.test(opts.size) ? opts.size : '540x1200'
|
|
1270
|
+
// Follow the current rotation: a landscape device must be encoded WxH-swapped, otherwise
|
|
1271
|
+
// screenrecord letterboxes it into a portrait frame.
|
|
1272
|
+
const size = await this.orientedSize(serial, baseSize)
|
|
1273
|
+
const bitRate = Math.min(Math.max(Number(opts.bitRate) || 2500000, 200000), 20000000)
|
|
1274
|
+
if (existing !== undefined) {
|
|
1275
|
+
if (existing.opts.size === size && existing.opts.bitRate === bitRate) return
|
|
1276
|
+
existing.stop()
|
|
1277
|
+
this.videoStreams.delete(serial)
|
|
1278
|
+
}
|
|
1279
|
+
if (this.adb === null || this.devices.get(serial)?.state !== 'device') {
|
|
1280
|
+
this.broadcast({ type: 'video-state', serial, running: false, error: 'device not attached' })
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
const stream = new VideoStream(this.adb, serial, { size, bitRate, baseSize }, {
|
|
1284
|
+
onFrame: (frame, key) => this.broadcastBinary({ type: 'svideo', serial, key, ts: Date.now() }, frame),
|
|
1285
|
+
onInit: (init) => this.broadcast({ type: 'svideo-init', serial, codec: init.codec, avcC: init.avcC.toString('base64'), size }),
|
|
1286
|
+
onState: (state) => this.broadcast({ type: 'video-state', serial, size, bitRate, display: this.displaySnapshot()[serial], ...state }),
|
|
1287
|
+
})
|
|
1288
|
+
this.videoStreams.set(serial, stream)
|
|
1289
|
+
stream.start()
|
|
1290
|
+
this.broadcast({ type: 'video-state', serial, running: true, size, bitRate })
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Live encoder stats per device (fps / Mbps / active tier). */
|
|
1294
|
+
videoStats() {
|
|
1295
|
+
const out = {}
|
|
1296
|
+
for (const [serial, stream] of this.videoStreams) out[serial] = stream.stats()
|
|
1297
|
+
return out
|
|
1298
|
+
}
|
|
1299
|
+
|
|
731
1300
|
/** Broadcast a binary frame: 4-byte header length + JSON header + payload. */
|
|
732
1301
|
broadcastBinary(header, payload) {
|
|
733
1302
|
const headerBuf = Buffer.from(JSON.stringify(header), 'utf8')
|
|
@@ -902,6 +1471,11 @@ function makeRoutes(engine) {
|
|
|
902
1471
|
streaming: [...engine.streams.keys()],
|
|
903
1472
|
eventsStreaming: [...engine.eventsStreams.keys()],
|
|
904
1473
|
screenCapable: true,
|
|
1474
|
+
videoCapable: true,
|
|
1475
|
+
videoStreaming: [...engine.videoStreams.keys()],
|
|
1476
|
+
videoStats: engine.videoStats(),
|
|
1477
|
+
displays: engine.displaySnapshot(),
|
|
1478
|
+
inputInjected: engine.inputReady(),
|
|
905
1479
|
currentPackage: engine.currentPackage,
|
|
906
1480
|
currentVersion: OWN_MANIFEST.version,
|
|
907
1481
|
latestVersion,
|
|
@@ -952,6 +1526,13 @@ function makeRoutes(engine) {
|
|
|
952
1526
|
if (serial === '' || command === '') { writeJson(res, 400, { error: 'serial and command are required' }); return }
|
|
953
1527
|
const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : 15000
|
|
954
1528
|
try {
|
|
1529
|
+
// Gestures go through the long-lived injector when possible: the stock `input`
|
|
1530
|
+
// command costs a JVM boot (~480ms) per call, this costs a few milliseconds.
|
|
1531
|
+
const injected = await engine.tryInject(serial, command)
|
|
1532
|
+
if (injected !== null) {
|
|
1533
|
+
writeJson(res, 200, { ok: true, code: 0, stdout: '', stderr: '', injected: true, ms: injected.ms })
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
955
1536
|
const result = await runAdbFull(engine.adb, ['-s', serial, 'shell', command], timeoutMs)
|
|
956
1537
|
writeJson(res, 200, result)
|
|
957
1538
|
} catch (error) {
|
|
@@ -1148,6 +1729,11 @@ function makeRoutes(engine) {
|
|
|
1148
1729
|
if (engine.adb === null) { writeJson(res, 500, { error: 'adb not found' }); return }
|
|
1149
1730
|
if (serial === '' || !engine.devices.has(serial)) { writeJson(res, 400, { error: 'device not attached' }); return }
|
|
1150
1731
|
if (!Number.isInteger(code) || code <= 0) { writeJson(res, 400, { error: `unknown key: ${key || '(none)'}` }); return }
|
|
1732
|
+
const injected = await engine.tryInject(serial, `input keyevent ${code}`)
|
|
1733
|
+
if (injected !== null) {
|
|
1734
|
+
writeJson(res, 200, { ok: true, code: 0, stdout: '', stderr: '', injected: true, ms: injected.ms })
|
|
1735
|
+
return
|
|
1736
|
+
}
|
|
1151
1737
|
const result = await runAdbFull(engine.adb, ['-s', serial, 'shell', `input keyevent ${code}`], 15000)
|
|
1152
1738
|
writeJson(res, 200, { ok: result.ok, code: result.code, stdout: result.stdout, stderr: result.stderr })
|
|
1153
1739
|
},
|
|
@@ -1340,6 +1926,9 @@ function makeRoutes(engine) {
|
|
|
1340
1926
|
} else if (frame?.type === 'screen' && typeof frame.serial === 'string') {
|
|
1341
1927
|
// Panel toggles the per-device live screen stream (1fps screencap).
|
|
1342
1928
|
engine.setScreen(frame.serial, frame.on === true)
|
|
1929
|
+
} else if (frame?.type === 'video' && typeof frame.serial === 'string') {
|
|
1930
|
+
// Panel toggles the H.264 screen stream (screenrecord → 30fps path).
|
|
1931
|
+
void engine.setVideo(frame.serial, frame.on === true, { size: frame.size, bitRate: frame.bitRate })
|
|
1343
1932
|
}
|
|
1344
1933
|
})
|
|
1345
1934
|
ws.on('close', () => { engine.clients.delete(ws) })
|
|
@@ -3068,6 +3657,7 @@ export function apply(ctx, config) {
|
|
|
3068
3657
|
devices: engine.deviceList(),
|
|
3069
3658
|
streaming: [...engine.streams.keys()],
|
|
3070
3659
|
eventsStreaming: [...engine.eventsStreams.keys()],
|
|
3660
|
+
videoStreaming: [...engine.videoStreams.keys()],
|
|
3071
3661
|
bufferSizes: Object.fromEntries([...engine.buffers.entries()].map(([s, b]) => [s, b.length])),
|
|
3072
3662
|
currentPackage: engine.currentPackage,
|
|
3073
3663
|
}),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Auto-generated - do not edit by hand.
|
|
2
|
+
// Base64 of lib/injector.jar (dex of tmp-inject/src/dsh/Injector.java): the long-lived
|
|
3
|
+
// app_process input injector. Embedded so the host can deploy it with a single shell write,
|
|
4
|
+
// without relying on db push (its sync protocol is fragile on flaky USB links).
|
|
5
|
+
// Rebuild with: node tmp-build-injector.mjs
|
|
6
|
+
export const INJECTOR_JAR_BASE64 = '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'
|