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/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=141'
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 < 6000
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
- await mkdir(this.dir, { recursive: true })
302
- this.startedAt = Date.now()
303
- this.error = ''
304
- const size = await this.adb(['-s', this.serial, 'shell', 'wm', 'size'], { timeout: 6000 })
305
- const found = /(\d+)x(\d+)/.exec(String(size.out ?? ''))
306
- if (found !== null) this.physical = { w: Number(found[1]), h: Number(found[2]) }
307
- await this.snapshot()
308
- this.alignScreen()
309
- this.timer = setInterval(() => this.pump(), 200)
310
- this.timer.unref?.()
311
- this.pushTimer = setInterval(() => this.push(), PUSH_TICK_MS)
312
- this.pushTimer.unref?.()
313
- void this.startRecorder()
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
- /** 有人在看才抓帧;没人看就空转并顺手关掉转码进程。 @returns {void} */
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
- const remote = `/sdcard/dsh-${this.serial.replace(/[^\w.-]/g, '_')}.mp4`
889
- while (!this.stopping) {
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
- const recorded = await this.adb([
892
- '-s', this.serial, 'shell', 'screenrecord',
893
- '--time-limit', String(SEGMENT_SECONDS),
894
- '--bit-rate', '2000000', '--size', this.recordSize(), remote,
895
- ], { timeout: (SEGMENT_SECONDS + 20) * 1000, direct: true })
896
- if (this.stopping) break
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
- /** 停掉两条子进程并断开观看者。 @returns {void} */
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
- const unsubscribe = stream.subscribe((bytes) => {
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
- const CALL_TIMEOUT_MS = 8_000
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
- /** python3 是否存在(不存在就没有 OCR)。 */
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 existsSync(WORKER) && existsSync('/usr/bin/python3')
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
- const child = spawn('/usr/bin/python3', [WORKER], { stdio: ['pipe', 'pipe', 'pipe'] })
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
- reject(new Error('OCR 进程启动超时'))
65
- kill()
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
- clearTimeout(timer)
82
- resolve()
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
- clearTimeout(timer)
96
- reject(error)
97
- kill()
148
+ fail(error)
98
149
  })
99
- child.on('exit', () => {
100
- clearTimeout(timer)
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
- }, CALL_TIMEOUT_MS)
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.stdin.write(`${path}\n`)
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.1",
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
  }