dsh-mobile-use 1.0.0 → 1.0.3
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/client.js +68 -12
- package/client.rev +1 -1
- package/index.js +335 -83
- package/mirror.js +122 -21
- package/ocr.js +88 -17
- package/package.json +7 -3
- package/selftest.mjs +64 -12
- package/stream.js +103 -32
- package/test/coords.test.mjs +129 -0
- package/test/symbols.test.mjs +73 -0
- package/test/timers.test.mjs +60 -0
package/stream.js
CHANGED
|
@@ -211,6 +211,8 @@ function containsIdr(chunk) {
|
|
|
211
211
|
|
|
212
212
|
/** 最后一个观众走后,录屏再活这么久:侧栏重载/切页回来时能立刻出画面,不用等 2 秒起流。 */
|
|
213
213
|
const IDLE_GRACE_MS = 20000
|
|
214
|
+
/** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启不会互相认领对方的录屏。 */
|
|
215
|
+
const RUN_TAG = Math.random().toString(36).slice(2, 8)
|
|
214
216
|
|
|
215
217
|
export class LiveStream {
|
|
216
218
|
/**
|
|
@@ -224,10 +226,14 @@ export class LiveStream {
|
|
|
224
226
|
this.height = options.height ?? 1280
|
|
225
227
|
this.bitrate = options.bitrate ?? 4_000_000
|
|
226
228
|
this.recorder = null
|
|
229
|
+
/* 设备端 pid 文件名:停止时只按这个 pid 精确收尾,绝不 pkill 别人在录的屏。 */
|
|
230
|
+
this.recorderPidFile = ''
|
|
227
231
|
this.remuxer = null
|
|
228
232
|
this.stopping = false
|
|
229
233
|
this.restartTimer = null
|
|
230
234
|
this.clients = new Set()
|
|
235
|
+
/* 由 mirror 装上:问一句「镜像那边还有人在看吗」,空闲回收时用。 */
|
|
236
|
+
this.stillWanted = null
|
|
231
237
|
this.init = null
|
|
232
238
|
this.pending = Buffer.alloc(0)
|
|
233
239
|
/** 待拼的 box 字节。 @type {Buffer} */
|
|
@@ -307,11 +313,17 @@ export class LiveStream {
|
|
|
307
313
|
/** 有人要看 JPEG 帧时才起解码器:不看不花 CPU。 @returns {boolean} 是否可用(解码器起来后 300 ms 内有帧)。 */
|
|
308
314
|
enableJpeg() {
|
|
309
315
|
this.jpegWantedAt = Date.now()
|
|
310
|
-
|
|
316
|
+
/* 只看帧(/frame 轮询)也会起录屏:没人再来取帧就按同一套宽限停掉,否则设备一直在录。
|
|
317
|
+
必须排在这里之后:start() 会清掉空闲定时器(它以为有人回来了)。 */
|
|
318
|
+
if (this.jpegChild !== null) {
|
|
319
|
+
this.scheduleIdleStop()
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
311
322
|
if (this.stopping) return false
|
|
312
323
|
if (Date.now() - this.jpegOpenAt < 2000) return false
|
|
313
324
|
this.jpegOpenAt = Date.now()
|
|
314
325
|
this.start()
|
|
326
|
+
this.scheduleIdleStop()
|
|
315
327
|
LiveStream.reapJpeg(this.serial)
|
|
316
328
|
const child = spawn('ffmpeg', [
|
|
317
329
|
'-hide_banner', '-loglevel', 'error',
|
|
@@ -330,7 +342,7 @@ export class LiveStream {
|
|
|
330
342
|
if (!(globalThis.__dshAdbJpeg.get(this.serial) instanceof Set)) globalThis.__dshAdbJpeg.set(this.serial, new Set())
|
|
331
343
|
globalThis.__dshAdbJpeg.get(this.serial).add(child)
|
|
332
344
|
child.on('exit', () => globalThis.__dshAdbJpeg.get(this.serial)?.delete(child))
|
|
333
|
-
child.stdout.on('data', (chunk) => this.feedJpeg(chunk))
|
|
345
|
+
child.stdout.on('data', (chunk) => this.guard('feed-jpeg', () => this.feedJpeg(chunk)))
|
|
334
346
|
child.stderr.on('data', (buf) => {
|
|
335
347
|
const text = String(buf).trim()
|
|
336
348
|
if (text !== '') this.error = text.slice(-300)
|
|
@@ -394,12 +406,24 @@ export class LiveStream {
|
|
|
394
406
|
}
|
|
395
407
|
|
|
396
408
|
/** 观众都走了:留一段宽限再停,重开侧栏就不用重新起流。 @returns {void} */
|
|
397
|
-
scheduleIdleStop() {
|
|
409
|
+
scheduleIdleStop(delayMs = IDLE_GRACE_MS) {
|
|
398
410
|
if (this.idleTimer !== null) return
|
|
399
|
-
this.idleTimer = setTimeout(() => {
|
|
411
|
+
this.idleTimer = setTimeout(() => this.guard('idle-stop', () => {
|
|
400
412
|
this.idleTimer = null
|
|
401
|
-
if (this.clients.size
|
|
402
|
-
|
|
413
|
+
if (this.clients.size > 0 || this.waiting.size > 0) return
|
|
414
|
+
/* 只看帧的人每隔一秒来一次,取帧时间戳会被不断刷新;差多少补多少,不重复排满宽限。 */
|
|
415
|
+
/* 镜像那边还有常连观看者(/live)时不能停:它不走 clients,只靠这个回调说话。 */
|
|
416
|
+
if (typeof this.stillWanted === 'function' && this.stillWanted()) {
|
|
417
|
+
this.scheduleIdleStop()
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const left = IDLE_GRACE_MS - (Date.now() - this.jpegWantedAt)
|
|
421
|
+
if (left > 250) {
|
|
422
|
+
this.scheduleIdleStop(left)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
this.stop()
|
|
426
|
+
}), Math.max(0, delayMs))
|
|
403
427
|
this.idleTimer.unref?.()
|
|
404
428
|
}
|
|
405
429
|
|
|
@@ -429,6 +453,8 @@ export class LiveStream {
|
|
|
429
453
|
this.intentional = true
|
|
430
454
|
const child = this.recorder
|
|
431
455
|
this.recorder = null
|
|
456
|
+
/* 换 IDR 前先收掉自己这次的设备端录屏,只按 pid 杀,不碰别人的。 */
|
|
457
|
+
this.killOwnRecorder()
|
|
432
458
|
this.init = null
|
|
433
459
|
this.error = ''
|
|
434
460
|
this.lastKeyChunks = []
|
|
@@ -445,17 +471,62 @@ export class LiveStream {
|
|
|
445
471
|
}
|
|
446
472
|
|
|
447
473
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
474
|
+
* 设备端精确收尾:按启动时写下的 pid 文件找到「自己这一次」的 screenrecord,
|
|
475
|
+
* 核对进程名还是 screenrecord 再 kill。pid 拿不到就不杀(只记日志),
|
|
476
|
+
* 让它按 time-limit 自己结束,也不会误伤别人的录屏。
|
|
477
|
+
* @param {string} [pidFile] - pid 文件;默认用当前这次录屏的。
|
|
450
478
|
* @returns {void}
|
|
451
479
|
*/
|
|
452
|
-
|
|
480
|
+
killOwnRecorder(pidFile = this.recorderPidFile) {
|
|
481
|
+
const safe = String(pidFile).replace(/[^\w./-]/g, '')
|
|
482
|
+
if (safe === '') {
|
|
483
|
+
this.debug('kill-device skipped: no pid file')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if (this.recorderPidFile === pidFile) this.recorderPidFile = ''
|
|
487
|
+
const script = `p='${safe}'; r=$(cat "$p" 2>/dev/null); rm -f "$p"; if [ -n "$r" ] && [ "$(cat /proc/$r/comm 2>/dev/null)" = 'screenrecord' ]; then kill -9 "$r"; echo killed:$r; else echo skip:$r; fi`
|
|
453
488
|
try {
|
|
454
|
-
const child = spawn(this.adbPath, ['-s', this.serial, 'shell',
|
|
489
|
+
const child = spawn(this.adbPath, ['-s', this.serial, 'shell', script], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
490
|
+
let out = ''
|
|
491
|
+
child.stdout?.on('data', (chunk) => { out = (out + String(chunk)).slice(-120) })
|
|
492
|
+
child.on('exit', () => this.debug('kill-device ' + (out.trim() || 'no-reply')))
|
|
493
|
+
child.on('error', () => this.debug('kill-device spawn-error'))
|
|
494
|
+
child.unref?.()
|
|
495
|
+
} catch {
|
|
496
|
+
this.debug('kill-device threw')
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 正常到时长退出的清理:只删自己的 pid 文件,不 kill(pid 可能已被别人复用)。
|
|
502
|
+
* @returns {void}
|
|
503
|
+
*/
|
|
504
|
+
dropRecorderPidFile() {
|
|
505
|
+
const pidFile = this.recorderPidFile
|
|
506
|
+
this.recorderPidFile = ''
|
|
507
|
+
const safe = String(pidFile).replace(/[^\w./-]/g, '')
|
|
508
|
+
if (safe === '') return
|
|
509
|
+
try {
|
|
510
|
+
const child = spawn(this.adbPath, ['-s', this.serial, 'shell', 'rm', '-f', safe], { stdio: 'ignore' })
|
|
455
511
|
child.on('error', () => {})
|
|
456
512
|
child.unref?.()
|
|
457
513
|
} catch {
|
|
458
|
-
/*
|
|
514
|
+
/* 清不掉就留着,几十字节,不影响主流程 */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 定时器与子进程回调的兜底:这里抛出的异常会冒到 host 顶层,整个 DSH 以 1 退出
|
|
520
|
+
* (2026-10-27 `this.killStale` 两次事故),所以宁可这一步不做也要写日志。
|
|
521
|
+
* @param {string} where - 出问题的路径,写进 stream-debug.log。
|
|
522
|
+
* @param {() => void} run - 真正要做的事。
|
|
523
|
+
* @returns {void}
|
|
524
|
+
*/
|
|
525
|
+
guard(where, run) {
|
|
526
|
+
try {
|
|
527
|
+
run()
|
|
528
|
+
} catch (error) {
|
|
529
|
+
this.debug(`guard ${where}: ${String(error?.stack ?? error).slice(0, 400)}`)
|
|
459
530
|
}
|
|
460
531
|
}
|
|
461
532
|
|
|
@@ -475,30 +546,28 @@ export class LiveStream {
|
|
|
475
546
|
spawnRecorder() {
|
|
476
547
|
if (this.stopping) return
|
|
477
548
|
const size = `${this.width}x${this.height}`
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
]
|
|
549
|
+
this.sessions += 1
|
|
550
|
+
const safeSerial = this.serial.replace(/[^\w.-]/g, '_')
|
|
551
|
+
const pidFile = `/data/local/tmp/dsh-mobile-use-stream-${safeSerial}-${RUN_TAG}-${this.sessions}.pid`
|
|
552
|
+
this.recorderPidFile = pidFile
|
|
553
|
+
/* exec 让 shell 的 pid 就是 screenrecord 的 pid:停止时按这个 pid 精确 kill。 */
|
|
554
|
+
const command = `sh -c 'echo $$ > ${pidFile}; exec screenrecord --output-format=h264 --time-limit=${RECORD_LIMIT} --size=${size} --bit-rate=${this.bitrate} -'`
|
|
555
|
+
const args = ['-s', this.serial, 'exec-out', command]
|
|
486
556
|
const child = spawn(this.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
487
557
|
this.recorder = child
|
|
488
|
-
this.sessions += 1
|
|
489
558
|
this.debug('spawn')
|
|
490
559
|
let stderr = ''
|
|
491
560
|
child.stderr.setEncoding('utf8')
|
|
492
561
|
child.stderr.on('data', (chunk) => {
|
|
493
562
|
stderr = (stderr + chunk).slice(-2000)
|
|
494
563
|
})
|
|
495
|
-
child.stdout.on('data', (chunk) => this.feedRaw(chunk))
|
|
496
|
-
child.on('error', (error) => {
|
|
564
|
+
child.stdout.on('data', (chunk) => this.guard('feed-raw', () => this.feedRaw(chunk)))
|
|
565
|
+
child.on('error', (error) => this.guard('recorder-error', () => {
|
|
497
566
|
this.error = String(error?.message ?? error)
|
|
498
567
|
this.recorder = null
|
|
499
568
|
this.scheduleRestart()
|
|
500
|
-
})
|
|
501
|
-
child.on('exit', (code) => {
|
|
569
|
+
}))
|
|
570
|
+
child.on('exit', (code) => this.guard('recorder-exit', () => {
|
|
502
571
|
if (this.recorder === child) this.recorder = null
|
|
503
572
|
this.closeRemuxer()
|
|
504
573
|
if (this.stopping) return
|
|
@@ -512,23 +581,25 @@ export class LiveStream {
|
|
|
512
581
|
}
|
|
513
582
|
/* 正常到时长或编码器重启都走这里:换一份 moov,前端按 INIT 帧重建 MediaSource。 */
|
|
514
583
|
if (this.clients.size > 0) this.broadcast(packFrame(FRAME_END, Buffer.alloc(0)))
|
|
584
|
+
/* 录满时长正常退出:screenrecord 已经不在了,只删自己的 pid 文件(不 kill,免 pid 复用误伤)。 */
|
|
585
|
+
if (code === 0) this.dropRecorderPidFile()
|
|
515
586
|
if (wasIntentional && this.stopping) return
|
|
516
587
|
this.scheduleRestart()
|
|
517
|
-
})
|
|
588
|
+
}))
|
|
518
589
|
}
|
|
519
590
|
|
|
520
591
|
/** 到点自动续上,避免中断超过半秒。 @returns {void} */
|
|
521
592
|
scheduleRestart() {
|
|
522
593
|
if (this.stopping || this.restartTimer !== null) return
|
|
523
594
|
if (this.clients.size === 0 && this.waiting.size === 0) return
|
|
524
|
-
this.restartTimer = setTimeout(() => {
|
|
595
|
+
this.restartTimer = setTimeout(() => this.guard('restart', () => {
|
|
525
596
|
this.restartTimer = null
|
|
526
597
|
if (!this.stopping && (this.clients.size > 0 || this.waiting.size > 0)) {
|
|
527
|
-
/* 上一轮可能是被 SIGKILL 的 adb
|
|
528
|
-
|
|
598
|
+
/* 上一轮可能是被 SIGKILL 的 adb:先按自己的 pid 清掉设备端残留,再开新录屏。 */
|
|
599
|
+
this.killOwnRecorder()
|
|
529
600
|
this.spawnRecorder()
|
|
530
601
|
}
|
|
531
|
-
}, 120)
|
|
602
|
+
}), 120)
|
|
532
603
|
this.restartTimer.unref?.()
|
|
533
604
|
}
|
|
534
605
|
|
|
@@ -587,7 +658,7 @@ export class LiveStream {
|
|
|
587
658
|
'-f', 'mp4', 'pipe:1',
|
|
588
659
|
], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
589
660
|
this.remuxer = child
|
|
590
|
-
child.stdout.on('data', (chunk) => this.feedMp4(chunk))
|
|
661
|
+
child.stdout.on('data', (chunk) => this.guard('feed-mp4', () => this.feedMp4(chunk)))
|
|
591
662
|
child.stderr.on('data', (buf) => {
|
|
592
663
|
const text = String(buf).trim()
|
|
593
664
|
if (text !== '') this.error = text.slice(-300)
|
|
@@ -776,6 +847,8 @@ export class LiveStream {
|
|
|
776
847
|
this.closeJpeg()
|
|
777
848
|
const child = this.recorder
|
|
778
849
|
this.recorder = null
|
|
850
|
+
/* 本地杀进程不等于设备端停了:先按自己记下的 pid 收掉设备端录屏,否则下一次开流会撞上。 */
|
|
851
|
+
this.killOwnRecorder()
|
|
779
852
|
if (child !== null) {
|
|
780
853
|
try {
|
|
781
854
|
child.kill('SIGKILL')
|
|
@@ -785,8 +858,6 @@ export class LiveStream {
|
|
|
785
858
|
}
|
|
786
859
|
this.intentional = true
|
|
787
860
|
this.stopping = false
|
|
788
|
-
/* 本地杀进程不等于设备端停了:显式收掉,否则下一次开流会撞上另一个录屏在跑。 */
|
|
789
|
-
if (this.sessions > 0) this.killStale()
|
|
790
861
|
this.init = null
|
|
791
862
|
this.lastKeyChunks = []
|
|
792
863
|
this.waiting.clear()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 坐标换算回归:从 client.js 里切出真实的 pictureRect / turned / devicePoint 源码,
|
|
3
|
+
* 在 node 里用假 DOM 矩形跑。侧栏在右边时坐标曾经被减两次(视觉中心点成 0,600),
|
|
4
|
+
* 这条测试就是那份审阅复现的固化版:改坏就红。
|
|
5
|
+
*
|
|
6
|
+
* node test/coords.test.mjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const source = fs.readFileSync(path.join(here, '..', 'client.js'), 'utf8')
|
|
16
|
+
|
|
17
|
+
const start = source.indexOf('/* 画面元素:MSE 用 video')
|
|
18
|
+
const end = source.indexOf('/* 平移边界')
|
|
19
|
+
assert.ok(start > 0 && end > start, '没找到几何换算那一段源码')
|
|
20
|
+
|
|
21
|
+
const geometry = source.slice(start, end)
|
|
22
|
+
const factory = new Function(
|
|
23
|
+
'engine', 'videoRef', 'imgRef', 'canvasRef', 'screen', 'frameSize', 'Math',
|
|
24
|
+
`${geometry}\n return { devicePoint, pictureRect, turned }`,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 造一个用给定矩形假装的 DOM 元素。
|
|
29
|
+
* @param {number} left - 视口左边缘。
|
|
30
|
+
* @param {number} top - 视口上边缘。
|
|
31
|
+
* @param {number} width - 宽。
|
|
32
|
+
* @param {number} height - 高。
|
|
33
|
+
* @returns {{ getBoundingClientRect: () => any }} 元素桩。
|
|
34
|
+
*/
|
|
35
|
+
const box = (left, top, width, height) => ({ getBoundingClientRect: () => ({ left, top, width, height, right: left + width, bottom: top + height }) })
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 造一台设备状态下的换算器。
|
|
39
|
+
* @param {{ device: {w: number, h: number}, frame: {w: number, h: number}, canvas: any, media: any, engine?: string }} config - 现场。
|
|
40
|
+
* @returns {(point: {x: number, y: number}) => {x: number, y: number}} 换算函数。
|
|
41
|
+
*/
|
|
42
|
+
const make = ({ device, frame, canvas, media, engine = 'jpeg' }) => {
|
|
43
|
+
const { devicePoint } = factory(engine, { current: null }, { current: media }, { current: canvas }, device, frame, Math)
|
|
44
|
+
return devicePoint
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let pass = 0
|
|
48
|
+
/**
|
|
49
|
+
* 断言一点。
|
|
50
|
+
* @param {string} label - 检查项。
|
|
51
|
+
* @param {() => void} run - 断言体。
|
|
52
|
+
* @returns {void}
|
|
53
|
+
*/
|
|
54
|
+
const it = (label, run) => {
|
|
55
|
+
try {
|
|
56
|
+
run()
|
|
57
|
+
pass += 1
|
|
58
|
+
console.log(` ✅ ${label}`)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.log(` ❌ ${label}`)
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('\n坐标换算(源码级)\n')
|
|
66
|
+
|
|
67
|
+
/* 审阅给的复现:右栏里画面框 left=1000/top=100,显示 300x533.33,设备 1080x1920。
|
|
68
|
+
取画面正中心,正确结果是 (540,960);旧代码会算成 (0,600)。 */
|
|
69
|
+
it('右栏里点画面正中心 = 设备正中心', () => {
|
|
70
|
+
const toDevice = make({
|
|
71
|
+
device: { w: 1080, h: 1920 },
|
|
72
|
+
frame: { w: 300, h: 533.333 },
|
|
73
|
+
canvas: box(1000, 100, 300, 533.333),
|
|
74
|
+
media: box(1000, 100, 300, 533.333),
|
|
75
|
+
})
|
|
76
|
+
assert.deepEqual(toDevice({ x: 150, y: 266.666 }), { x: 540, y: 960 })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('画面有上下留边时按画面矩形换算(不是画布)', () => {
|
|
80
|
+
const toDevice = make({
|
|
81
|
+
device: { w: 1080, h: 1350 },
|
|
82
|
+
frame: { w: 400, h: 500 },
|
|
83
|
+
canvas: box(20, 40, 400, 600),
|
|
84
|
+
media: box(20, 90, 400, 500),
|
|
85
|
+
})
|
|
86
|
+
assert.deepEqual(toDevice({ x: 200, y: 250 }), { x: 540, y: 540 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('画面有左右留边时按画面矩形换算', () => {
|
|
90
|
+
const toDevice = make({
|
|
91
|
+
device: { w: 1080, h: 1080 },
|
|
92
|
+
frame: { w: 400, h: 400 },
|
|
93
|
+
canvas: box(0, 0, 500, 400),
|
|
94
|
+
media: box(50, 0, 400, 400),
|
|
95
|
+
})
|
|
96
|
+
assert.deepEqual(toDevice({ x: 250, y: 200 }), { x: 540, y: 540 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('画面外沿夹到边界,不会溢成负数', () => {
|
|
100
|
+
const toDevice = make({
|
|
101
|
+
device: { w: 1080, h: 1920 },
|
|
102
|
+
frame: { w: 300, h: 533 },
|
|
103
|
+
canvas: box(1000, 100, 300, 533),
|
|
104
|
+
media: box(1000, 100, 300, 533),
|
|
105
|
+
})
|
|
106
|
+
assert.deepEqual(toDevice({ x: -50, y: 900 }), { x: 0, y: 1920 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('横屏画面 + 竖屏设备(转过 90°)映射到长边', () => {
|
|
110
|
+
const toDevice = make({
|
|
111
|
+
device: { w: 2136, h: 3200 },
|
|
112
|
+
frame: { w: 2400, h: 1080 },
|
|
113
|
+
canvas: box(0, 0, 2400, 1080),
|
|
114
|
+
media: box(0, 0, 2400, 1080),
|
|
115
|
+
})
|
|
116
|
+
assert.deepEqual(toDevice({ x: 1200, y: 540 }), { x: 1068, y: 1600 })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('MSE 走 video 元素,抓帧走 img,两条路都拿画面矩形', () => {
|
|
120
|
+
const canvas = box(0, 0, 300, 500)
|
|
121
|
+
const video = box(0, 0, 300, 500)
|
|
122
|
+
const img = box(10, 20, 280, 460)
|
|
123
|
+
const mse = factory('mse', { current: video }, { current: img }, { current: canvas }, { w: 1080, h: 1800 }, { w: 300, h: 500 }, Math)
|
|
124
|
+
assert.deepEqual(mse.devicePoint({ x: 150, y: 250 }), { x: 540, y: 900 })
|
|
125
|
+
const jpeg = factory('jpeg', { current: video }, { current: img }, { current: canvas }, { w: 1080, h: 1800 }, { w: 300, h: 500 }, Math)
|
|
126
|
+
assert.deepEqual(jpeg.devicePoint({ x: 150, y: 250 }), { x: 540, y: 900 })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
console.log(`\n${pass} 项通过\n`)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 悬空 this 调用扫描:rename 后残留 `this.oldName()` 会让插件在回调里抛 TypeError,
|
|
3
|
+
* 而宿主不隔离插件异常 —— 一次空转定时器就能打死整个 DSH(2026-10-27 的 killStale 事故)。
|
|
4
|
+
* 只做一件事:文件里每个 `this.foo(` 都要能找到定义(方法简写或 this.foo = 赋值)。
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
const FILES = ['stream.js', 'mirror.js', 'index.js', 'client.js']
|
|
9
|
+
|
|
10
|
+
/** 从源码里收集「哪些名字在这份文件里是可调用的 this 成员」。 */
|
|
11
|
+
function knownMembers(source) {
|
|
12
|
+
const names = new Set()
|
|
13
|
+
/* this.foo = ... 的赋值(含 this.foo = function / 箭头函数)。 */
|
|
14
|
+
for (const m of source.matchAll(/this\.([A-Za-z_$][\w$]*)\s*=(?!=)/g)) names.add(m[1])
|
|
15
|
+
/* 方法简写 / 类字段箭头函数:行首缩进后的 `foo(...) {` 或 `foo = (...) =>`。 */
|
|
16
|
+
for (const m of source.matchAll(/^\s{2,}(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/gm)) names.add(m[1])
|
|
17
|
+
for (const m of source.matchAll(/^\s{2,}([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/gm)) names.add(m[1])
|
|
18
|
+
/* 对象字面量里的方法(route 表这类)也给过。 */
|
|
19
|
+
for (const m of source.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function|\()/g)) names.add(m[1])
|
|
20
|
+
return names
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 常见内置/外部注入成员,不要求在文件内定义。 */
|
|
24
|
+
const BUILTIN = new Set([
|
|
25
|
+
'constructor', 'then', 'catch', 'finally', 'apply', 'call', 'bind', 'toString', 'valueOf',
|
|
26
|
+
'hasOwnProperty', 'push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'map', 'filter',
|
|
27
|
+
'forEach', 'find', 'findIndex', 'some', 'every', 'reduce', 'join', 'split', 'trim',
|
|
28
|
+
'replace', 'includes', 'startsWith', 'endsWith', 'padStart', 'padEnd', 'toFixed',
|
|
29
|
+
'getTime', 'toISOString', 'keys', 'values', 'entries', 'set', 'get', 'delete', 'has',
|
|
30
|
+
'clear', 'add', 'size', 'length', 'emit', 'on', 'once', 'off', 'removeListener', 'end',
|
|
31
|
+
'write', 'pipe', 'read', 'kill', 'ref', 'unref', 'stdin', 'stdout', 'stderr',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
let failures = 0
|
|
35
|
+
const it = (label, run) => {
|
|
36
|
+
try {
|
|
37
|
+
run()
|
|
38
|
+
console.log(` ✅ ${label}`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
failures += 1
|
|
41
|
+
console.log(` ❌ ${label}\n ${String(error?.message ?? error)}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const assert = (cond, msg) => {
|
|
45
|
+
if (!cond) throw new Error(msg)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const file of FILES) {
|
|
49
|
+
it(`${file}:this.* 调用都有定义`, () => {
|
|
50
|
+
const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
|
51
|
+
const known = knownMembers(source)
|
|
52
|
+
const dangling = new Set()
|
|
53
|
+
for (const m of source.matchAll(/this\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
54
|
+
const name = m[1]
|
|
55
|
+
if (known.has(name) || BUILTIN.has(name)) continue
|
|
56
|
+
dangling.add(name)
|
|
57
|
+
}
|
|
58
|
+
assert(dangling.size === 0, `悬空调用:${[...dangling].join(', ')}(改名后残留会让插件回调抛 TypeError,host 直接退出)`)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* 自检:故意造一份带残留调用的源码,确认扫描器抓得到,别写成永远绿的摆设。 */
|
|
63
|
+
it('扫描器能抓到悬空调用(自检)', () => {
|
|
64
|
+
const broken = 'class A {\n killOwn() {}\n stop() {\n if (this.n > 0) this.killStale()\n }\n}\n'
|
|
65
|
+
const known = knownMembers(broken)
|
|
66
|
+
const dangling = [...broken.matchAll(/this\.([A-Za-z_$][\w$]*)\s*\(/g)]
|
|
67
|
+
.map((m) => m[1])
|
|
68
|
+
.filter((name) => !known.has(name) && !BUILTIN.has(name))
|
|
69
|
+
assert(dangling.includes('killStale'), '扫描器没抓到 killStale,说明这个测试本身失效了')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
console.log(failures === 0 ? `\n=== 全部通过 ===` : `\n=== ${failures} 项失败 ===`)
|
|
73
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定时器兜底扫描:宿主不隔离插件异常,定时器回调里任何一个 TypeError 都会冒到顶层,
|
|
3
|
+
* 整个 DSH 以 1 退出(2026-10-27 `this.killStale` 两次事故就是空闲回收定时器 → stop())。
|
|
4
|
+
* 规则:host 侧所有节拍器/回收定时器的回调都要经过 guard(),单拍出错只记日志。
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
/** 以 this 成员形式挂出去的定时器(节拍器与空闲/续录回收)。 */
|
|
9
|
+
const PATTERN = /this\.(timer|pushTimer|idleTimer|restartTimer)\s*=\s*(?:setInterval|setTimeout)\(/g
|
|
10
|
+
|
|
11
|
+
/** 从注册点往后看多远算「同一个回调」。 */
|
|
12
|
+
const WINDOW = 160
|
|
13
|
+
|
|
14
|
+
/** 找出没走 guard 的定时器注册。 @param {string} source - 文件源码。 @returns {string[]} 违规点。 */
|
|
15
|
+
function unguarded(source) {
|
|
16
|
+
const bad = []
|
|
17
|
+
for (const m of source.matchAll(PATTERN)) {
|
|
18
|
+
const tail = source.slice(m.index, m.index + WINDOW)
|
|
19
|
+
if (!tail.includes('guard(')) bad.push(source.slice(m.index, m.index + 60).split('\n')[0])
|
|
20
|
+
}
|
|
21
|
+
return bad
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let failures = 0
|
|
25
|
+
const it = (label, run) => {
|
|
26
|
+
try {
|
|
27
|
+
run()
|
|
28
|
+
console.log(` ✅ ${label}`)
|
|
29
|
+
} catch (error) {
|
|
30
|
+
failures += 1
|
|
31
|
+
console.log(` ❌ ${label}\n ${String(error?.message ?? error)}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const assert = (cond, msg) => {
|
|
35
|
+
if (!cond) throw new Error(msg)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const file of ['stream.js', 'mirror.js']) {
|
|
39
|
+
it(`${file}:定时器回调都经过 guard`, () => {
|
|
40
|
+
const bad = unguarded(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'))
|
|
41
|
+
assert(bad.length === 0, `未兜底的定时器:${bad.join(' | ')}`)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('自检:没兜底的定时器会被抓出来', () => {
|
|
46
|
+
const fake = 'this.idleTimer = setTimeout(() => { this.stop() }, 20000)'
|
|
47
|
+
assert(unguarded(fake).length === 1, '扫描放过了一个裸定时器')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('自检:兜底后的定时器不误报', () => {
|
|
51
|
+
const fake = "this.idleTimer = setTimeout(() => guard('idle-stop', () => { this.stop() }), 20000)"
|
|
52
|
+
assert(unguarded(fake).length === 0, '兜底写法被误判')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (failures > 0) {
|
|
56
|
+
console.log(`=== ${failures} 项失败 ===`)
|
|
57
|
+
process.exitCode = 1
|
|
58
|
+
} else {
|
|
59
|
+
console.log('=== 全部通过 ===')
|
|
60
|
+
}
|