dsh-mobile-use 1.0.1 → 1.0.4
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 +94 -16
- package/client.rev +1 -1
- package/index.js +335 -83
- package/mirror.js +157 -32
- package/ocr.js +88 -17
- package/package.json +7 -3
- package/selftest.mjs +64 -12
- package/stream.js +214 -39
- package/test/coords.test.mjs +129 -0
- package/test/symbols.test.mjs +73 -0
- package/test/timers.test.mjs +60 -0
package/mirror.js
CHANGED
|
@@ -20,11 +20,26 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
|
|
23
|
-
import { LiveStream, packFrame } from './stream.js?v=
|
|
23
|
+
import { LiveStream, packFrame } from './stream.js?v=149'
|
|
24
24
|
|
|
25
25
|
/** 客户端与宿主共用的路由前缀。 */
|
|
26
26
|
export const ROUTE = '/dsh-mobile-use'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 节拍器回调兜底:异常冒到 host 顶层会直接把整个 DSH 带走(2026-10-27 事故),
|
|
30
|
+
* 所以单拍出错只记一行,不停服务、不退进程。
|
|
31
|
+
* @param {string} where - 出问题的路径。
|
|
32
|
+
* @param {() => void} run - 这一拍要做的事。
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
function guard(where, run) {
|
|
36
|
+
try {
|
|
37
|
+
run()
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(`[dsh-mobile-use] ${where}:`, error)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/** 最近一次标注里动效拼图的字节数,0 表示没凑够帧。 */
|
|
29
44
|
let lastStripBytes = 0
|
|
30
45
|
|
|
@@ -41,6 +56,10 @@ const LIVE_FPS = 3
|
|
|
41
56
|
const PUSH_TICK_MS = 200
|
|
42
57
|
/** 同时在飞的截图数:单张约 0.65s,3 张并发实测 4 帧/秒以上。 */
|
|
43
58
|
const CAPTURE_PARALLEL = 2
|
|
59
|
+
/** 没人看多久之后收掉分段录制:跟抓帧的空转宽限同一个数,别留两个口径。 */
|
|
60
|
+
const WATCH_GRACE_MS = 6000
|
|
61
|
+
/** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启时不会互相覆盖。 */
|
|
62
|
+
const REC_TAG = Math.random().toString(36).slice(2, 8)
|
|
44
63
|
|
|
45
64
|
/** 环形缓冲:10 秒一段,留 18 段 ≈ 3 分钟,超出的由 ffmpeg 自己覆盖。 */
|
|
46
65
|
const SEGMENT_SECONDS = 10
|
|
@@ -229,6 +248,9 @@ function pngToJpeg(png) {
|
|
|
229
248
|
})
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
/** 单个 MJPEG 连接的待写上限:慢客户端不能把宿主进程的堆吃光。 */
|
|
252
|
+
const MAX_LIVE_QUEUE = 2 * 1024 * 1024
|
|
253
|
+
|
|
232
254
|
/**
|
|
233
255
|
* 往一个 MJPEG 连接写一帧。
|
|
234
256
|
* @param {import('node:http').ServerResponse} res - 连接。
|
|
@@ -238,6 +260,12 @@ function pngToJpeg(png) {
|
|
|
238
260
|
*/
|
|
239
261
|
function writeFrame(res, frame, drop) {
|
|
240
262
|
try {
|
|
263
|
+
/* 慢客户端只许拖垮自己:待写超过上限就直接断连,让它重连,别在宿主里攒队列。 */
|
|
264
|
+
if (res.writableLength > MAX_LIVE_QUEUE) {
|
|
265
|
+
drop()
|
|
266
|
+
res.destroy()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
241
269
|
res.write(`--frame\r\ncontent-type: image/jpeg\r\ncontent-length: ${frame.length}\r\n\r\n`)
|
|
242
270
|
res.write(frame)
|
|
243
271
|
res.write('\r\n')
|
|
@@ -263,11 +291,17 @@ export class Mirror {
|
|
|
263
291
|
this.height = 0
|
|
264
292
|
this.watchers = new Set()
|
|
265
293
|
this.lastWatchAt = 0
|
|
294
|
+
/* 并发 start 的单飞句柄:见 start()。 */
|
|
295
|
+
this.startPromise = null
|
|
266
296
|
this.dropped = 0
|
|
267
297
|
this.seq = 0
|
|
268
298
|
this.waiters = []
|
|
269
299
|
this.grabMs = 0
|
|
270
300
|
this.recording = false
|
|
301
|
+
/* 分段录制的收尾:请求停止标记 + 设备端 pid 文件名(只杀自己这一次录屏)。 */
|
|
302
|
+
this.recorderStopRequested = false
|
|
303
|
+
this.recorderPidFile = ''
|
|
304
|
+
this.recorderSeq = 0
|
|
271
305
|
this.stopping = false
|
|
272
306
|
this.startedAt = 0
|
|
273
307
|
this.error = ''
|
|
@@ -293,24 +327,36 @@ export class Mirror {
|
|
|
293
327
|
* @returns {boolean} 有观看者(或刚有人来看)为真。
|
|
294
328
|
*/
|
|
295
329
|
anyoneWatching() {
|
|
296
|
-
return this.watchers.size > 0 || Date.now() - this.lastWatchAt <
|
|
330
|
+
return this.watchers.size > 0 || Date.now() - this.lastWatchAt < WATCH_GRACE_MS
|
|
297
331
|
}
|
|
298
332
|
|
|
299
333
|
async start() {
|
|
300
334
|
if (this.timer !== null || this.stopping) return
|
|
301
|
-
|
|
302
|
-
this.
|
|
303
|
-
this.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
335
|
+
/* 并发 start(页面重载 + /frame 同时到)只许跑一次:起两套定时器后,停的时候必漏一套。 */
|
|
336
|
+
if (this.startPromise !== null) return this.startPromise
|
|
337
|
+
this.startPromise = (async () => {
|
|
338
|
+
await mkdir(this.dir, { recursive: true })
|
|
339
|
+
this.startedAt = Date.now()
|
|
340
|
+
this.error = ''
|
|
341
|
+
const size = await this.adb(['-s', this.serial, 'shell', 'wm', 'size'], { timeout: 6000 })
|
|
342
|
+
const found = /(\d+)x(\d+)/.exec(String(size.out ?? ''))
|
|
343
|
+
if (found !== null) this.physical = { w: Number(found[1]), h: Number(found[2]) }
|
|
344
|
+
await this.snapshot()
|
|
345
|
+
this.alignScreen()
|
|
346
|
+
/* 等到这里再判一次:await 期间可能已经有人 stop 了。 */
|
|
347
|
+
if (this.stopping) return
|
|
348
|
+
this.timer = setInterval(() => guard('pump', () => this.pump()), 200)
|
|
349
|
+
this.timer.unref?.()
|
|
350
|
+
this.pushTimer = setInterval(() => guard('push', () => this.push()), PUSH_TICK_MS)
|
|
351
|
+
this.pushTimer.unref?.()
|
|
352
|
+
/* 录制跟观看者对齐:起服务时没人看就先不录,等 /frame 或 /live 把人带来再开。 */
|
|
353
|
+
this.ensureRecorder()
|
|
354
|
+
})().catch((error) => {
|
|
355
|
+
this.error = String(error?.message ?? error)
|
|
356
|
+
}).finally(() => {
|
|
357
|
+
this.startPromise = null
|
|
358
|
+
})
|
|
359
|
+
return this.startPromise
|
|
314
360
|
}
|
|
315
361
|
|
|
316
362
|
/**
|
|
@@ -328,22 +374,24 @@ export class Mirror {
|
|
|
328
374
|
this.screen = landscape ? { w: long, h: short } : { w: short, h: long }
|
|
329
375
|
}
|
|
330
376
|
|
|
331
|
-
/**
|
|
377
|
+
/** 有人在看才抓帧;没人看就空转,宽限期过了顺手把分段录制也收掉。 @returns {void} */
|
|
332
378
|
pump() {
|
|
333
379
|
if (this.stopping) return
|
|
334
380
|
if (!this.anyoneWatching()) {
|
|
335
381
|
if (this.inflight === 0) {
|
|
336
382
|
if (this.idleSince === 0) this.idleSince = Date.now()
|
|
337
383
|
}
|
|
384
|
+
this.ensureRecorder()
|
|
338
385
|
return
|
|
339
386
|
}
|
|
340
387
|
this.idleSince = 0
|
|
388
|
+
this.ensureRecorder()
|
|
341
389
|
/* 桥接帧够新就别再抓屏:两种源尺寸不同,混进同一池子画面会跳。 */
|
|
342
390
|
if (this.bridgeFresh !== null && this.bridgeFresh()) return
|
|
343
391
|
/* 每个节拍最多补一张,让 3 张在飞时错开完成,画面才匀。 */
|
|
344
392
|
if (this.inflight < CAPTURE_PARALLEL) {
|
|
345
393
|
this.inflight += 1
|
|
346
|
-
void this.grab().finally(() => { this.inflight -= 1 })
|
|
394
|
+
void this.grab().catch((error) => { this.error = String(error?.message ?? error) }).finally(() => { this.inflight -= 1 })
|
|
347
395
|
}
|
|
348
396
|
}
|
|
349
397
|
|
|
@@ -878,24 +926,76 @@ export class Mirror {
|
|
|
878
926
|
return frame
|
|
879
927
|
}
|
|
880
928
|
|
|
929
|
+
/**
|
|
930
|
+
* 该录就录、该停就停:只有有人在看才保留分段录制,没人看超过宽限期就收掉。
|
|
931
|
+
* 由 pump() 每 200ms 调一次,重复调用无副作用。
|
|
932
|
+
* @returns {void}
|
|
933
|
+
*/
|
|
934
|
+
ensureRecorder() {
|
|
935
|
+
if (this.stopping) return
|
|
936
|
+
if (this.anyoneWatching()) {
|
|
937
|
+
if (!this.recording) void this.startRecorder()
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (!this.recording) return
|
|
941
|
+
const idleAt = this.idleSince > 0 ? this.idleSince : this.lastWatchAt
|
|
942
|
+
if (idleAt > 0 && Date.now() - idleAt < WATCH_GRACE_MS) return
|
|
943
|
+
this.stopRecorder()
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 请求停掉分段录制:只按自己记下的设备端 pid 收尾,不碰别人的 screenrecord。
|
|
948
|
+
* @returns {void}
|
|
949
|
+
*/
|
|
950
|
+
stopRecorder() {
|
|
951
|
+
this.recorderStopRequested = true
|
|
952
|
+
const pidFile = this.recorderPidFile
|
|
953
|
+
this.recorderPidFile = ''
|
|
954
|
+
if (pidFile !== '') void this.killOwnRecorder(pidFile)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* 精确杀自己这一次录屏:pid 取自启动时写下的设备端文件,并核对进程名还是 screenrecord。
|
|
959
|
+
* pid 拿不到(文件为空/读不到)就退化为不杀,只记一条错误,交给下一段自己超时结束。
|
|
960
|
+
* @param {string} pidFile - 启动时写下的 pid 文件绝对路径。
|
|
961
|
+
* @returns {Promise<string>} 设备端回话:`killed:<pid>` 或 `skip:<原因>`。
|
|
962
|
+
*/
|
|
963
|
+
async killOwnRecorder(pidFile) {
|
|
964
|
+
const safe = String(pidFile).replace(/[^\w./-]/g, '')
|
|
965
|
+
if (safe === '') return 'skip:no-pid-file'
|
|
966
|
+
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`
|
|
967
|
+
const result = await this.adb(['-s', this.serial, 'shell', script], { timeout: 8000, direct: true }).catch(() => null)
|
|
968
|
+
const out = String(result?.out ?? '').trim()
|
|
969
|
+
if (out.startsWith('killed:')) return out
|
|
970
|
+
this.error = this.error || `录屏收尾没拿到 pid(${out || '没有回话'}),没杀设备端进程`
|
|
971
|
+
return out || 'skip:no-reply'
|
|
972
|
+
}
|
|
973
|
+
|
|
881
974
|
/**
|
|
882
975
|
* 循环分段录制:设备上录 10s 一段,拉回本机,只留最近 18 段。
|
|
976
|
+
* 每段启动时把 pid 写进设备端文件,停止时按 pid 精确 kill。
|
|
883
977
|
* @returns {Promise<void>} 停了才返回。
|
|
884
978
|
*/
|
|
885
979
|
async startRecorder() {
|
|
886
980
|
if (this.recording || this.stopping) return
|
|
887
981
|
this.recording = true
|
|
888
|
-
|
|
889
|
-
|
|
982
|
+
this.recorderStopRequested = false
|
|
983
|
+
const safeSerial = this.serial.replace(/[^\w.-]/g, '_')
|
|
984
|
+
const remote = `/sdcard/dsh-${safeSerial}.mp4`
|
|
985
|
+
while (!this.stopping && !this.recorderStopRequested) {
|
|
890
986
|
const started = Date.now()
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
987
|
+
this.recorderSeq += 1
|
|
988
|
+
const pidFile = `/data/local/tmp/dsh-mobile-use-rec-${safeSerial}-${REC_TAG}-${this.recorderSeq}.pid`
|
|
989
|
+
this.recorderPidFile = pidFile
|
|
990
|
+
/* exec 让 shell 的 pid 就是 screenrecord 的 pid:停的时候按这个 pid 精确 kill。 */
|
|
991
|
+
const command = `sh -c 'echo $$ > ${pidFile}; exec screenrecord --time-limit ${SEGMENT_SECONDS} --bit-rate 2000000 --size ${this.recordSize()} ${remote}'`
|
|
992
|
+
const recorded = await this.adb(['-s', this.serial, 'shell', command], {
|
|
993
|
+
timeout: (SEGMENT_SECONDS + 20) * 1000,
|
|
994
|
+
direct: true,
|
|
995
|
+
}).catch((error) => ({ ok: false, err: String(error?.message ?? error) }))
|
|
996
|
+
if (this.stopping || this.recorderStopRequested) break
|
|
897
997
|
if (!recorded.ok) {
|
|
898
|
-
this.error = recorded.err.trim().slice(0, 200) || '录制失败'
|
|
998
|
+
this.error = String(recorded.err ?? '').trim().slice(0, 200) || '录制失败'
|
|
899
999
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
900
1000
|
continue
|
|
901
1001
|
}
|
|
@@ -914,6 +1014,7 @@ export class Mirror {
|
|
|
914
1014
|
if (spent < SEGMENT_SECONDS * 1000) await new Promise((resolve) => setTimeout(resolve, Math.max(0, 400 - spent)))
|
|
915
1015
|
}
|
|
916
1016
|
this.recording = false
|
|
1017
|
+
this.recorderStopRequested = false
|
|
917
1018
|
}
|
|
918
1019
|
|
|
919
1020
|
/** 本机只留最近 18 段(约 3 分钟)。 @returns {Promise<void>} 删完。 */
|
|
@@ -961,9 +1062,10 @@ export class Mirror {
|
|
|
961
1062
|
return this.latest
|
|
962
1063
|
}
|
|
963
1064
|
|
|
964
|
-
/**
|
|
1065
|
+
/** 停掉两条子进程、收掉分段录制并断开观看者;重复调用无副作用。 @returns {void} */
|
|
965
1066
|
stop() {
|
|
966
1067
|
this.stopping = true
|
|
1068
|
+
this.stopRecorder()
|
|
967
1069
|
if (this.timer !== null) clearInterval(this.timer)
|
|
968
1070
|
this.timer = null
|
|
969
1071
|
if (this.pushTimer !== null) clearInterval(this.pushTimer)
|
|
@@ -1017,6 +1119,11 @@ export function setupMirror(ctx, tools) {
|
|
|
1017
1119
|
let stream = streams.get(serial)
|
|
1018
1120
|
if (stream === undefined) {
|
|
1019
1121
|
stream = new LiveStream({ serial, adb: tools.adb.path })
|
|
1122
|
+
/* 空闲回收前先问一句:镜像还有人在看(/live 常连或刚拉过帧)就别停。 */
|
|
1123
|
+
stream.stillWanted = () => {
|
|
1124
|
+
const mirror = mirrors.get(serial)
|
|
1125
|
+
return mirror !== undefined && mirror.anyoneWatching()
|
|
1126
|
+
}
|
|
1020
1127
|
streams.set(serial, stream)
|
|
1021
1128
|
}
|
|
1022
1129
|
return stream
|
|
@@ -1095,6 +1202,11 @@ export function setupMirror(ctx, tools) {
|
|
|
1095
1202
|
if (rect !== null && Number(rect.w) > 0) {
|
|
1096
1203
|
lines.push(`圈选区域(设备坐标):x=${Math.round(rect.x)} y=${Math.round(rect.y)} w=${Math.round(rect.w)} h=${Math.round(rect.h)}`)
|
|
1097
1204
|
}
|
|
1205
|
+
/* 圈是画在冻结帧上的:把「画面是几秒前的」交出去,免得把那一刻的控件位置当成现在。 */
|
|
1206
|
+
const frozenMs = Number(input.frozen_ms)
|
|
1207
|
+
if (Number.isFinite(frozenMs) && frozenMs >= 1) {
|
|
1208
|
+
lines.push(`这张图是一张 ${(frozenMs / 1000).toFixed(1)} 秒前冻结的画面,圈的位置以那一帧为准。`)
|
|
1209
|
+
}
|
|
1098
1210
|
if (tools.uiInRect !== undefined && rect !== null) {
|
|
1099
1211
|
const inside = await tools.uiInRect(String(input.serial ?? ''), rect).catch(() => [])
|
|
1100
1212
|
if (inside.length > 0) lines.push(`圈选区域内的控件:\n${inside.join('\n')}`)
|
|
@@ -1347,7 +1459,7 @@ export function setupMirror(ctx, tools) {
|
|
|
1347
1459
|
}
|
|
1348
1460
|
child.on('error', (error) => finish({ ok: false, reason: String(error && error.message ? error.message : error).slice(0, 200) }))
|
|
1349
1461
|
child.on('exit', (code) => finish(code === 0 ? { ok: true } : { ok: false, reason: '退出码 ' + code }))
|
|
1350
|
-
const tick = () => {
|
|
1462
|
+
const tick = () => guard('gesture-tick', () => {
|
|
1351
1463
|
if (index >= frames.length) {
|
|
1352
1464
|
child.stdin.end()
|
|
1353
1465
|
return
|
|
@@ -1355,13 +1467,13 @@ export function setupMirror(ctx, tools) {
|
|
|
1355
1467
|
child.stdin.write(stampFrame(frames[index], start + (index * step) / 1000))
|
|
1356
1468
|
index += 1
|
|
1357
1469
|
setTimeout(tick, step)
|
|
1358
|
-
}
|
|
1470
|
+
})
|
|
1359
1471
|
tick()
|
|
1360
|
-
setTimeout(() => {
|
|
1472
|
+
setTimeout(() => guard('gesture-timeout', () => {
|
|
1361
1473
|
if (done) return
|
|
1362
1474
|
child.kill('SIGKILL')
|
|
1363
1475
|
finish({ ok: false, reason: '注入超时' })
|
|
1364
|
-
}, ms + 4000)
|
|
1476
|
+
}), ms + 4000)
|
|
1365
1477
|
})
|
|
1366
1478
|
}
|
|
1367
1479
|
|
|
@@ -1531,7 +1643,9 @@ export function setupMirror(ctx, tools) {
|
|
|
1531
1643
|
})
|
|
1532
1644
|
res.flushHeaders()
|
|
1533
1645
|
let closed = false
|
|
1534
|
-
|
|
1646
|
+
/* 先占位再订阅:subscribe 可能同步补发初始化段,那时 unsubscribe 还没赋值。 */
|
|
1647
|
+
let unsubscribe = () => {}
|
|
1648
|
+
unsubscribe = stream.subscribe((bytes) => {
|
|
1535
1649
|
if (closed) return
|
|
1536
1650
|
/* 积压超过 2 MB 就断开重连:宁可重连一次,也不要让延迟越滚越大。 */
|
|
1537
1651
|
if (res.writableLength > 2 << 20) {
|
|
@@ -1588,6 +1702,8 @@ export function setupMirror(ctx, tools) {
|
|
|
1588
1702
|
void stream.enableJpeg()
|
|
1589
1703
|
/* 录屏到时长会停;每次有人拉帧都把它续上,否则画面就只剩抓帧那一路。 */
|
|
1590
1704
|
void stream.start()
|
|
1705
|
+
/* start() 会清空闲定时器,所以续命之后重新排一次:面板关掉 20 秒后自动停录。 */
|
|
1706
|
+
stream.scheduleIdleStop()
|
|
1591
1707
|
})
|
|
1592
1708
|
const pump = () => {
|
|
1593
1709
|
const jpeg = stream.jpeg
|
|
@@ -1636,6 +1752,8 @@ export function setupMirror(ctx, tools) {
|
|
|
1636
1752
|
fps: mirror.measuredFps(),
|
|
1637
1753
|
live: mirror.latest !== null,
|
|
1638
1754
|
watchers: mirror.watchers.size,
|
|
1755
|
+
/* 录制是后台行为:状态里如实报,别让人以为没人看时也在录。 */
|
|
1756
|
+
recording: mirror.recording,
|
|
1639
1757
|
dropped: mirror.dropped,
|
|
1640
1758
|
grabMs: mirror.grabMs,
|
|
1641
1759
|
pushed: mirror.pushed,
|
|
@@ -1867,6 +1985,7 @@ export function setupMirror(ctx, tools) {
|
|
|
1867
1985
|
return {
|
|
1868
1986
|
serial: chosen,
|
|
1869
1987
|
live: mirror.latest !== null,
|
|
1988
|
+
recording: mirror.recording,
|
|
1870
1989
|
frames: mirror.frames,
|
|
1871
1990
|
fps: mirror.measuredFps(),
|
|
1872
1991
|
bufferedSeconds: segments.length * SEGMENT_SECONDS,
|
|
@@ -1879,6 +1998,12 @@ export function setupMirror(ctx, tools) {
|
|
|
1879
1998
|
return mirror === undefined ? [] : mirror.segments()
|
|
1880
1999
|
},
|
|
1881
2000
|
stopAll() {
|
|
2001
|
+
/* 先停 H.264 直播:每个实例都有自己的设备端录屏和 ffmpeg 子进程,比抓帧泵重得多。
|
|
2002
|
+
只清 mirrors 会把这些子进程留在设备上继续录,卸载插件后还在跑。 */
|
|
2003
|
+
for (const stream of streams.values()) {
|
|
2004
|
+
try { stream.stop() } catch { /* 已经停了 */ }
|
|
2005
|
+
}
|
|
2006
|
+
streams.clear()
|
|
1882
2007
|
for (const mirror of mirrors.values()) mirror.stop()
|
|
1883
2008
|
mirrors.clear()
|
|
1884
2009
|
for (const res of events) {
|
package/ocr.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @module dsh-mobile-use/ocr
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { spawn } from 'node:child_process'
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
@@ -18,7 +18,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
18
18
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
19
19
|
const WORKER = join(HERE, 'ocr_worker.py')
|
|
20
20
|
const IDLE_MS = 45_000
|
|
21
|
-
|
|
21
|
+
/** 一次 ocrImage 的总时限:启动 + 识别共用,避免两边各自超时叠成半分钟。 */
|
|
22
|
+
const CALL_TIMEOUT_MS = 15_000
|
|
23
|
+
/** 启动 worker 的单独上限:worker 卡在 import Vision 时要尽快放弃。 */
|
|
24
|
+
const START_TIMEOUT_MS = 8_000
|
|
25
|
+
const PYTHON = '/usr/bin/python3'
|
|
22
26
|
|
|
23
27
|
let worker = null
|
|
24
28
|
let ready = null
|
|
@@ -27,9 +31,46 @@ let seq = 0
|
|
|
27
31
|
const pending = new Map()
|
|
28
32
|
let buffer = ''
|
|
29
33
|
|
|
30
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* 探一次本机能不能跑 OCR:平台、worker 文件、python 能否 import Vision 三个条件。
|
|
36
|
+
* 结果缓存,插件生命周期内只探一次(worker 起不来时也别每次调用都 spawn 一次 python)。
|
|
37
|
+
* @returns {{ available: boolean, reason: string }} 可用性;不可用时 `reason` 说明原因。
|
|
38
|
+
*/
|
|
39
|
+
function probe() {
|
|
40
|
+
if (process.platform !== 'darwin') return { available: false, reason: `本机是 ${process.platform},Vision OCR 只有 macOS 有` }
|
|
41
|
+
if (!existsSync(WORKER)) return { available: false, reason: `缺少 ${WORKER}` }
|
|
42
|
+
if (!existsSync(PYTHON)) return { available: false, reason: `缺少 ${PYTHON}` }
|
|
43
|
+
/* 只探 import:worker 真正用到的 Vision/Quartz 都在这里,缺 pyobjc 会在启动后立刻崩。 */
|
|
44
|
+
const check = spawnSync(PYTHON, ['-c', 'import Vision, Quartz, Foundation'], { timeout: 5000, stdio: 'ignore' })
|
|
45
|
+
if (check.error) {
|
|
46
|
+
return { available: false, reason: `${PYTHON} 起不来:${String(check.error.message ?? check.error).slice(0, 120)}` }
|
|
47
|
+
}
|
|
48
|
+
if (check.status !== 0) return { available: false, reason: `${PYTHON} 缺 Vision/Quartz(pyobjc-framework-Vision)` }
|
|
49
|
+
return { available: true, reason: '' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let availability = null
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 本机 OCR 状态:可用性和不可用原因。
|
|
56
|
+
* @returns {{ available: boolean, reason: string }} `reason` 可直接写进工具结果,让上层回退到文本树/截图。
|
|
57
|
+
*/
|
|
58
|
+
export function ocrStatus() {
|
|
59
|
+
if (availability === null) availability = probe()
|
|
60
|
+
return availability
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** python3 与 Vision 是否可用(不存在就没有 OCR)。 @returns {boolean} 可用为 true。 */
|
|
31
64
|
export function ocrAvailable() {
|
|
32
|
-
return
|
|
65
|
+
return ocrStatus().available
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 拿不可用原因;可用时返回空串。
|
|
70
|
+
* @returns {string} 人话原因,例如「本机是 linux,Vision OCR 只有 macOS 有」。
|
|
71
|
+
*/
|
|
72
|
+
export function ocrReason() {
|
|
73
|
+
return ocrStatus().reason
|
|
33
74
|
}
|
|
34
75
|
|
|
35
76
|
function kill() {
|
|
@@ -57,13 +98,22 @@ function armIdle() {
|
|
|
57
98
|
|
|
58
99
|
function start() {
|
|
59
100
|
if (ready) return ready
|
|
101
|
+
const state = ocrStatus()
|
|
102
|
+
if (!state.available) return Promise.reject(new Error(`本机没有 OCR:${state.reason}`))
|
|
60
103
|
ready = new Promise((resolve, reject) => {
|
|
61
|
-
|
|
104
|
+
let settled = false
|
|
105
|
+
const fail = (error) => {
|
|
106
|
+
if (settled) return
|
|
107
|
+
settled = true
|
|
108
|
+
clearTimeout(timer)
|
|
109
|
+
reject(error)
|
|
110
|
+
kill()
|
|
111
|
+
}
|
|
112
|
+
const child = spawn(PYTHON, [WORKER], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
62
113
|
worker = child
|
|
63
114
|
const timer = setTimeout(() => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}, 15_000)
|
|
115
|
+
fail(new Error('OCR 进程启动超时'))
|
|
116
|
+
}, START_TIMEOUT_MS)
|
|
67
117
|
child.stdout.setEncoding('utf8')
|
|
68
118
|
child.stdout.on('data', (chunk) => {
|
|
69
119
|
buffer += chunk
|
|
@@ -78,8 +128,11 @@ function start() {
|
|
|
78
128
|
continue
|
|
79
129
|
}
|
|
80
130
|
if (message.ready === true) {
|
|
81
|
-
|
|
82
|
-
|
|
131
|
+
if (!settled) {
|
|
132
|
+
settled = true
|
|
133
|
+
clearTimeout(timer)
|
|
134
|
+
resolve()
|
|
135
|
+
}
|
|
83
136
|
continue
|
|
84
137
|
}
|
|
85
138
|
const entry = pending.get(message.path)
|
|
@@ -92,15 +145,16 @@ function start() {
|
|
|
92
145
|
child.stderr.setEncoding('utf8')
|
|
93
146
|
child.stderr.on('data', () => {})
|
|
94
147
|
child.on('error', (error) => {
|
|
95
|
-
|
|
96
|
-
reject(error)
|
|
97
|
-
kill()
|
|
148
|
+
fail(error)
|
|
98
149
|
})
|
|
99
|
-
child.on('exit', () => {
|
|
100
|
-
|
|
150
|
+
child.on('exit', (code, signal) => {
|
|
151
|
+
/* 关键:ready 之前就退出时必须 reject,否则 start() 的 Promise 永远挂着,
|
|
152
|
+
上层 ocrImage 的 await 也跟着挂死(非 macOS / 缺 pyobjc 时的实际表现)。 */
|
|
153
|
+
fail(new Error(`OCR 进程启动时退出(code=${String(code)}, signal=${String(signal)})`))
|
|
101
154
|
if (worker === child) {
|
|
102
155
|
worker = null
|
|
103
156
|
ready = null
|
|
157
|
+
buffer = ''
|
|
104
158
|
for (const [, entry] of pending) entry.reject(new Error('OCR 进程退出'))
|
|
105
159
|
pending.clear()
|
|
106
160
|
}
|
|
@@ -116,13 +170,17 @@ function start() {
|
|
|
116
170
|
* `x/y/w/h` 是归一化坐标,原点在**左下角**(Vision 的习惯)。
|
|
117
171
|
*/
|
|
118
172
|
export async function ocrImage(path) {
|
|
173
|
+
/* 启动和识别共用一个 deadline:启动慢了,识别就少等,整体最多 CALL_TIMEOUT_MS。 */
|
|
174
|
+
const deadline = Date.now() + CALL_TIMEOUT_MS
|
|
119
175
|
await start()
|
|
120
176
|
armIdle()
|
|
177
|
+
const left = deadline - Date.now()
|
|
178
|
+
if (left <= 0) throw new Error('OCR 超时')
|
|
121
179
|
return new Promise((resolve, reject) => {
|
|
122
180
|
const timer = setTimeout(() => {
|
|
123
181
|
pending.delete(path)
|
|
124
182
|
reject(new Error('OCR 超时'))
|
|
125
|
-
},
|
|
183
|
+
}, left)
|
|
126
184
|
pending.set(path, {
|
|
127
185
|
resolve: (result) => {
|
|
128
186
|
clearTimeout(timer)
|
|
@@ -133,7 +191,20 @@ export async function ocrImage(path) {
|
|
|
133
191
|
reject(error)
|
|
134
192
|
},
|
|
135
193
|
})
|
|
136
|
-
worker
|
|
194
|
+
/* worker 可能在 start() resolve 之后、这里之前就退了:写不进去就明确失败,别抛同步异常。 */
|
|
195
|
+
if (worker === null) {
|
|
196
|
+
pending.delete(path)
|
|
197
|
+
clearTimeout(timer)
|
|
198
|
+
reject(new Error('OCR 进程已退出'))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
worker.stdin.write(`${path}\n`)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
pending.delete(path)
|
|
205
|
+
clearTimeout(timer)
|
|
206
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
207
|
+
}
|
|
137
208
|
})
|
|
138
209
|
}
|
|
139
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-use",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "真机调试插件:界面文本树、动效数据、实时画面侧栏与圈选标注",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"ocr_worker.py",
|
|
41
41
|
".env.example",
|
|
42
42
|
"THIRD_PARTY_NOTICES.md",
|
|
43
|
-
"docs"
|
|
43
|
+
"docs",
|
|
44
|
+
"test"
|
|
44
45
|
],
|
|
45
46
|
"license": "MIT",
|
|
46
47
|
"author": "dsh-mobile-use contributors",
|
|
@@ -60,5 +61,8 @@
|
|
|
60
61
|
"uiautomator",
|
|
61
62
|
"mirror",
|
|
62
63
|
"debugging"
|
|
63
|
-
]
|
|
64
|
+
],
|
|
65
|
+
"scripts": {
|
|
66
|
+
"test": "node test/coords.test.mjs && node test/symbols.test.mjs && node test/timers.test.mjs"
|
|
67
|
+
}
|
|
64
68
|
}
|