@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/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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'