@ponydev/dsh-phone 0.3.2 → 0.4.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/hos.js ADDED
@@ -0,0 +1,1144 @@
1
+ // dsh-phone · 鸿蒙 transport 层(DevEco 模拟器 + hdc)
2
+ // 只做三件事:找可执行文件、跑命令、把输出解析成结构化数据。
3
+ // 所有命令一律 execFile + 参数数组,不经过 shell;名称 / serial / 坐标 / 文本都先过白名单。
4
+ //
5
+ // 两个入口:
6
+ // HarmonyEmulator DevEco 模拟器:-list / -imageList / -create / -start / -stop / -delete,
7
+ // 截图走 -screenshot(PNG),点按/滑动/旋转/电源走 -click / -slide / -rotation / -power,
8
+ // 按键和文字没有 CLI 能力,内部转给 hdc uitest。
9
+ // HarmonyHdc 真机 / 云手机:hdc list targets,截图走 snapshot_display + file recv(JPEG),
10
+ // 输入走 uitest uiInput。默认从列表里过滤 model=emulator 的本地模拟器。
11
+ import { execFile } from 'node:child_process'
12
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
13
+ import { homedir, tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ /** hdc serial 白名单:字母数字开头,允许 . _ : -,最长 128 */
17
+ export const HDC_SERIAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$/
18
+ /** 模拟器实例名:字母/数字/中文开头,允许空格 . _ -,最长 64;不允许路径分隔符 */
19
+ export const EMULATOR_NAME_RE = /^[A-Za-z0-9\u4e00-\u9fa5][A-Za-z0-9\u4e00-\u9fa5 ._\-]{0,63}$/
20
+ /**
21
+ * uitest 文字白名单:字母、数字、空格和少量安全标点。
22
+ * hdc shell 最终会经过设备 shell,`$` `;` `` ` `` `|` `&` 引号等一律不收。
23
+ */
24
+ export const TEXT_RE = /^[\p{L}\p{N} .,!@%+_=:\/-]{1,200}$/u
25
+ /** hdc uitest keyEvent 支持的名字(其余只收数字 keyID) */
26
+ export const KEYS = Object.freeze({
27
+ back: 'Back',
28
+ home: 'Home',
29
+ power: 'Power',
30
+ })
31
+ /** 设备类型 → Emulator CLI 文档里的取值 */
32
+ export const DEVICE_TYPES = Object.freeze({
33
+ phone: 'Phone',
34
+ foldable: 'Foldable',
35
+ widefold: 'WideFold',
36
+ 'wide fold': 'WideFold',
37
+ triplefold: 'TripleFold',
38
+ 'triple fold': 'TripleFold',
39
+ tablet: 'Tablet',
40
+ '2in1': '2in1',
41
+ '2in1 foldable': '2in1 Foldable',
42
+ wearable: 'Wearable',
43
+ wearablekid: 'WearableKid',
44
+ 'wearable kid': 'WearableKid',
45
+ tv: 'TV',
46
+ car: 'Car',
47
+ })
48
+
49
+ const DEFAULT_TIMEOUT = 8000
50
+ const EMULATOR_TIMEOUT = 15000
51
+ const SHOT_TIMEOUT = 20000
52
+ const TRANSFER_TIMEOUT = 30000
53
+ const START_TIMEOUT = 90_000
54
+ const STOP_TIMEOUT = 30_000
55
+ const CREATE_TIMEOUT = 180_000
56
+ const REMOVE_TIMEOUT = 60_000
57
+ const POLL_INTERVAL = 500
58
+ const LIST_TTL = 1500
59
+ const PROFILE_TTL = 5 * 60 * 1000
60
+ const IMAGE_TTL = 5 * 60 * 1000
61
+ const SCREEN_TTL = 5 * 60 * 1000
62
+ const MAX_BUFFER = 8 * 1024 * 1024
63
+
64
+ function fail(code, message) {
65
+ const error = new Error(message)
66
+ error.code = code
67
+ return error
68
+ }
69
+
70
+ function positive(value) {
71
+ const number = Number(value)
72
+ return Number.isFinite(number) && number > 0 ? number : 0
73
+ }
74
+
75
+ function text(value) {
76
+ return typeof value === 'string' ? value.trim() : ''
77
+ }
78
+
79
+ function boolValue(value) {
80
+ return value === true || value === 'true' || value === 1 || value === '1'
81
+ }
82
+
83
+ function numberOrNull(value) {
84
+ if (value === undefined || value === null || value === '') return null
85
+ const number = Number(value)
86
+ return Number.isFinite(number) && number > 0 ? number : null
87
+ }
88
+
89
+ function firstLine(buffer) {
90
+ if (!buffer) return ''
91
+ const source = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
92
+ return source.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] || ''
93
+ }
94
+
95
+ function sleep(ms) {
96
+ return new Promise((resolve) => setTimeout(resolve, ms))
97
+ }
98
+
99
+ function unique(list) {
100
+ const seen = new Set()
101
+ const result = []
102
+ for (const item of list) {
103
+ if (!item || seen.has(item)) continue
104
+ seen.add(item)
105
+ result.push(item)
106
+ }
107
+ return result
108
+ }
109
+
110
+ function clampInt(value, min, max, label) {
111
+ const number = Math.round(Number(value))
112
+ if (!Number.isFinite(number)) throw fail('bad-coordinate', `${label} 必须是数字`)
113
+ return Math.min(max, Math.max(min, number))
114
+ }
115
+
116
+ function hasPoint(event) {
117
+ return (
118
+ event.x !== undefined &&
119
+ event.x !== null &&
120
+ event.y !== undefined &&
121
+ event.y !== null
122
+ )
123
+ }
124
+
125
+ function keyArgument(value) {
126
+ const key = text(value)
127
+ if (!key) throw fail('bad-key', '按键不能为空(可用 back / home / power 或数字 keyID)')
128
+ const named = KEYS[key.toLowerCase()]
129
+ if (named) return named
130
+ if (/^\d{1,5}$/.test(key)) return key
131
+ throw fail('bad-key', `不支持的按键: ${key}(可用 back / home / power 或数字 keyID)`)
132
+ }
133
+
134
+ function textArgument(event) {
135
+ const value = typeof event.text === 'string' ? event.text : typeof event.value === 'string' ? event.value : ''
136
+ if (!TEXT_RE.test(value)) {
137
+ throw fail('bad-text', '文字为空或包含不支持的字符(只允许文字/数字/空格和 .,!@%+_=:/- )')
138
+ }
139
+ return value
140
+ }
141
+
142
+ function exec(file, args, options = {}) {
143
+ const timeout = options.timeout || DEFAULT_TIMEOUT
144
+ const maxBuffer = options.maxBuffer || MAX_BUFFER
145
+ return new Promise((resolve, reject) => {
146
+ execFile(file, args, { timeout, maxBuffer, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => {
147
+ if (error) {
148
+ const message = firstLine(stderr) || firstLine(stdout) || error.message || 'command failed'
149
+ const failure = fail(options.code || 'command-failed', message)
150
+ failure.stdout = stdout
151
+ failure.stderr = stderr
152
+ failure.exitCode = typeof error.code === 'number' ? error.code : undefined
153
+ failure.timedOut = error.killed === true
154
+ reject(failure)
155
+ return
156
+ }
157
+ resolve({ stdout, stderr })
158
+ })
159
+ })
160
+ }
161
+
162
+ function jsonArray(source, code, label) {
163
+ const raw = String(source || '')
164
+ const start = raw.indexOf('[')
165
+ const end = raw.lastIndexOf(']')
166
+ if (start < 0 || end <= start) throw fail(code, `${label} 输出不是 JSON 数组`)
167
+ let value
168
+ try {
169
+ value = JSON.parse(raw.slice(start, end + 1))
170
+ } catch {
171
+ throw fail(code, `${label} 输出不是合法 JSON 数组`)
172
+ }
173
+ if (!Array.isArray(value)) throw fail(code, `${label} 输出不是 JSON 数组`)
174
+ return value
175
+ }
176
+
177
+ function parseEmulatorVersion(source) {
178
+ const match = /Emulator\s*:\s*([0-9][0-9A-Za-z.\-]*)/.exec(String(source || ''))
179
+ return match ? match[1] : null
180
+ }
181
+
182
+ function parseHdcVersion(source) {
183
+ const match = /Ver:\s*([0-9A-Za-z.\-]+)/.exec(String(source || ''))
184
+ return match ? match[1] : null
185
+ }
186
+
187
+ function isLoopbackSerial(serial) {
188
+ return /^(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\]):\d{1,5}$/.test(serial)
189
+ }
190
+
191
+ function isEmulatorProfile(profile) {
192
+ if (!profile) return false
193
+ return /emulator/i.test(profile.model || '') || /emulator/i.test(profile.name || '')
194
+ }
195
+
196
+ function readScreenshot(file) {
197
+ try {
198
+ return readFileSync(file)
199
+ } catch (error) {
200
+ throw fail('bad-screenshot', `读取截图文件失败:${error.message || error}`)
201
+ }
202
+ }
203
+
204
+ function newestScreenshot(dir) {
205
+ let files
206
+ try {
207
+ files = readdirSync(dir).filter((name) => /\.png$/i.test(name))
208
+ } catch {
209
+ return null
210
+ }
211
+ if (files.length === 0) return null
212
+ files.sort((left, right) => statSync(join(dir, right)).mtimeMs - statSync(join(dir, left)).mtimeMs)
213
+ return join(dir, files[0])
214
+ }
215
+
216
+ /**
217
+ * 等截图文件真正写完。
218
+ * Emulator -screenshot 会先打印成功再在后台落盘;实测命令返回后约 70ms 才出现文件,
219
+ * 而且大小会继续增长,所以按「文件存在 + 大小连续两次不变」判定写完。
220
+ */
221
+ async function waitForScreenshot(dir, timeout) {
222
+ const deadline = Date.now() + timeout
223
+ let previous = -1
224
+ let stable = 0
225
+ while (Date.now() < deadline) {
226
+ const file = newestScreenshot(dir)
227
+ if (file) {
228
+ let size = 0
229
+ try {
230
+ size = statSync(file).size
231
+ } catch {
232
+ size = 0
233
+ }
234
+ if (size > 0) {
235
+ if (size === previous) {
236
+ stable += 1
237
+ if (stable >= 2) return file
238
+ } else {
239
+ previous = size
240
+ stable = 0
241
+ }
242
+ }
243
+ }
244
+ await sleep(100)
245
+ }
246
+ return null
247
+ }
248
+
249
+ let devEcoAppsCache = null
250
+ /** DevEco Studio.app 候选(桌面版优先),找不到就返回空数组。 */
251
+ function devEcoApps() {
252
+ if (devEcoAppsCache) return devEcoAppsCache
253
+ const apps = []
254
+ for (const base of ['/Applications', join(homedir(), 'Applications')]) {
255
+ let entries = []
256
+ try {
257
+ entries = readdirSync(base)
258
+ } catch {
259
+ continue
260
+ }
261
+ for (const entry of entries) {
262
+ if (/^DevEco.*\.app$/i.test(entry)) apps.push(join(base, entry))
263
+ }
264
+ }
265
+ apps.sort()
266
+ const desktop = apps.filter((item) => /DevEco-Studio\.app$/i.test(item))
267
+ devEcoAppsCache = unique([...desktop, ...apps])
268
+ return devEcoAppsCache
269
+ }
270
+
271
+ function emulatorCandidates(configuredPath) {
272
+ const candidates = []
273
+ if (configuredPath) candidates.push(configuredPath)
274
+ for (const env of ['DSH_EMULATOR_PATH', 'DEVECO_EMULATOR_PATH']) {
275
+ const value = process.env[env]
276
+ if (value) candidates.push(value)
277
+ }
278
+ for (const app of devEcoApps()) candidates.push(join(app, 'Contents', 'tools', 'emulator', 'Emulator'))
279
+ candidates.push('/Applications/DevEco-Studio.app/Contents/tools/emulator/Emulator')
280
+ candidates.push('Emulator')
281
+ return unique(candidates)
282
+ }
283
+
284
+ function hdcCandidates(configuredPath) {
285
+ const candidates = []
286
+ if (configuredPath) candidates.push(configuredPath)
287
+ for (const env of ['DSH_HDC_PATH', 'HDC_PATH']) {
288
+ const value = process.env[env]
289
+ if (value) candidates.push(value)
290
+ }
291
+ candidates.push('hdc')
292
+ candidates.push(join(homedir(), '.local', 'bin', 'hdc'))
293
+ const sdkHome = process.env.DEVECO_SDK_HOME
294
+ if (sdkHome) candidates.push(join(sdkHome, 'default', 'openharmony', 'toolchains', 'hdc'))
295
+ for (const app of devEcoApps()) {
296
+ candidates.push(join(app, 'Contents', 'sdk', 'default', 'openharmony', 'toolchains', 'hdc'))
297
+ }
298
+ candidates.push('/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc')
299
+ return unique(candidates)
300
+ }
301
+
302
+ /** 解析 `Emulator -list -details` 的 JSON。纯函数,smoke 直接测它。 */
303
+ export function parseEmulatorList(source) {
304
+ const devices = []
305
+ for (const item of jsonArray(source, 'emulator-parse', 'Emulator -list -details')) {
306
+ if (!item || typeof item !== 'object') continue
307
+ const name = text(item.name)
308
+ if (!name) continue
309
+ const running = boolValue(item.isRunning)
310
+ devices.push({
311
+ serial: name,
312
+ state: running ? 'device' : 'offline',
313
+ model: text(item.productModel) || text(item.deviceModel) || name,
314
+ platform: 'harmony',
315
+ kind: 'simulator',
316
+ width: numberOrNull(item['hw.lcd.single.width']) ?? numberOrNull(item['hw.lcd.double.width']),
317
+ height: numberOrNull(item['hw.lcd.single.height']) ?? numberOrNull(item['hw.lcd.double.height']),
318
+ transport: 'emulator',
319
+ osVersion: text(item['os.osVersion']) || null,
320
+ deviceType: text(item.deviceType) || null,
321
+ })
322
+ }
323
+ return devices
324
+ }
325
+
326
+ /** 解析 `Emulator -imageList -deviceType phone` 的 JSON。纯函数。 */
327
+ export function parseImageList(source) {
328
+ const images = []
329
+ for (const item of jsonArray(source, 'emulator-parse', 'Emulator -imageList')) {
330
+ if (!item || typeof item !== 'object') continue
331
+ const osVersion = text(item.osVersion)
332
+ if (!osVersion) continue
333
+ const apiMatch = /\(([^()]+)\)/.exec(osVersion)
334
+ images.push({
335
+ osVersion,
336
+ apiVersion: text(item['os.apiVersion']) || (apiMatch ? apiMatch[1].trim() : null),
337
+ softwareVersion: text(item.SoftWareVersion) || text(item.softwareVersion) || null,
338
+ deviceType: text(item.deviceType).toLowerCase() || 'phone',
339
+ downloaded: boolValue(item.downloaded),
340
+ releaseType: text(item.releaseType) || null,
341
+ upgradable: boolValue(item.upgradable),
342
+ })
343
+ }
344
+ return images
345
+ }
346
+
347
+ /** 解析 `hdc list targets [-v]`。纯函数;-v 每行是 serial\t\t类型\t状态\t... */
348
+ export function parseHdcTargets(source) {
349
+ const bySerial = new Map()
350
+ for (const raw of String(source || '').split(/\r?\n/)) {
351
+ const line = raw.trim()
352
+ if (!line || line.startsWith('[')) continue
353
+ const parts = line.split(/\t+/).map((part) => part.trim()).filter(Boolean)
354
+ const serial = parts[0]
355
+ if (!HDC_SERIAL_RE.test(serial)) continue
356
+ let connection = null
357
+ let state = 'device'
358
+ if (parts.length >= 3) {
359
+ connection = parts[1].toUpperCase()
360
+ state = /connect/i.test(parts[2]) ? 'device' : 'offline'
361
+ } else if (parts.length === 2) {
362
+ connection = parts[1].toUpperCase()
363
+ }
364
+ bySerial.set(serial, { serial, state, connection })
365
+ }
366
+ return [...bySerial.values()]
367
+ }
368
+
369
+ /** PNG 签名(8 字节)。 */
370
+ export function isPng(buffer) {
371
+ return (
372
+ Buffer.isBuffer(buffer) &&
373
+ buffer.length >= 8 &&
374
+ buffer[0] === 0x89 &&
375
+ buffer.toString('ascii', 1, 4) === 'PNG'
376
+ )
377
+ }
378
+
379
+ /** JPEG SOI(FF D8)。 */
380
+ export function isJpeg(buffer) {
381
+ return Buffer.isBuffer(buffer) && buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8
382
+ }
383
+
384
+ /** 从 PNG IHDR 读宽高。 */
385
+ export function parsePngSize(buffer) {
386
+ if (!isPng(buffer) || buffer.length < 24) return null
387
+ const width = buffer.readUInt32BE(16)
388
+ const height = buffer.readUInt32BE(20)
389
+ return width > 0 && height > 0 ? { width, height } : null
390
+ }
391
+
392
+ /** 从 JPEG SOFn 读宽高(截图轮询用,不依赖第三方库)。 */
393
+ export function parseJpegSize(buffer) {
394
+ if (!isJpeg(buffer) || buffer.length < 4) return null
395
+ let offset = 2
396
+ while (offset + 4 <= buffer.length) {
397
+ if (buffer[offset] !== 0xff) {
398
+ offset += 1
399
+ continue
400
+ }
401
+ let marker = buffer[offset + 1]
402
+ while (marker === 0xff && offset + 2 < buffer.length) {
403
+ offset += 1
404
+ marker = buffer[offset + 1]
405
+ }
406
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
407
+ offset += 2
408
+ continue
409
+ }
410
+ if (marker === 0xd9 || marker === 0xda) return null
411
+ if (offset + 4 > buffer.length) return null
412
+ const length = buffer.readUInt16BE(offset + 2)
413
+ if (length < 2) return null
414
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
415
+ if (offset + 9 > buffer.length) return null
416
+ const height = buffer.readUInt16BE(offset + 5)
417
+ const width = buffer.readUInt16BE(offset + 7)
418
+ return width > 0 && height > 0 ? { width, height } : null
419
+ }
420
+ offset += 2 + length
421
+ }
422
+ return null
423
+ }
424
+
425
+ /** 从 `snapshot_display` 输出里抓宽高(最后一条 width/height 最准)。 */
426
+ export function parseSnapshotSize(source) {
427
+ const matches = [...String(source || '').matchAll(/width:\s*(\d+)\s*,\s*height:\s*(\d+)/gi)]
428
+ if (matches.length === 0) return null
429
+ const last = matches[matches.length - 1]
430
+ const width = Number(last[1])
431
+ const height = Number(last[2])
432
+ return width > 0 && height > 0 ? { width, height } : null
433
+ }
434
+
435
+ /** 设备类型归一化(大小写 / 空格 / 下划线都不敏感)。 */
436
+ export function normalizeDeviceType(value) {
437
+ const key = String(value === undefined || value === null ? '' : value)
438
+ .trim()
439
+ .toLowerCase()
440
+ .replace(/[\s_]+/g, ' ')
441
+ const normalized = DEVICE_TYPES[key]
442
+ if (!normalized) {
443
+ throw fail('bad-device-type', `不支持的设备类型: ${value || '(empty)'}(可用 ${Object.keys(DEVICE_TYPES).join(' / ')})`)
444
+ }
445
+ return normalized
446
+ }
447
+
448
+ /** 校验并归一化模拟器实例名。 */
449
+ export function normalizeEmulatorName(value) {
450
+ const name = text(value)
451
+ if (!EMULATOR_NAME_RE.test(name)) {
452
+ throw fail('bad-emulator-name', '模拟器名称不合法(字母/数字/中文开头,允许空格 . _ -,最长 64)')
453
+ }
454
+ return name
455
+ }
456
+
457
+ function assertHdcSerial(serial) {
458
+ if (typeof serial !== 'string' || !HDC_SERIAL_RE.test(serial)) {
459
+ throw fail('bad-serial', '设备序列号不合法')
460
+ }
461
+ return serial
462
+ }
463
+
464
+ /**
465
+ * hdc 进程封装。一个实例同时服务所有设备,写操作按 serial 串行化。
466
+ * 默认 list() 过滤掉 model=emulator 的本地模拟器(模拟器归 HarmonyEmulator 管)。
467
+ */
468
+ export class HarmonyHdc {
469
+ constructor(options = {}) {
470
+ this.configuredPath = text(options.path)
471
+ this.timeout = positive(options.timeout) || DEFAULT_TIMEOUT
472
+ this.shotTimeout = positive(options.shotTimeout) || SHOT_TIMEOUT
473
+ this.transferTimeout = positive(options.transferTimeout) || TRANSFER_TIMEOUT
474
+ this.bin = null
475
+ this.info = null
476
+ this._resolving = null
477
+ this._targetsCache = { at: 0, list: [] }
478
+ this._profiles = new Map()
479
+ this._screens = new Map()
480
+ this._locks = new Map()
481
+ this._sequence = 0
482
+ }
483
+
484
+ /** 找到可用的 hdc(配置值 → PATH → ~/.local/bin → DevEco SDK)。 */
485
+ async resolve() {
486
+ if (this.info) return this.info
487
+ if (this._resolving) return this._resolving
488
+ this._resolving = (async () => {
489
+ for (const candidate of hdcCandidates(this.configuredPath)) {
490
+ if (candidate.includes('/') && !existsSync(candidate)) continue
491
+ try {
492
+ const { stdout } = await exec(candidate, ['-v'], { timeout: 5000, code: 'hdc-missing' })
493
+ this.bin = candidate
494
+ this.info = { bin: candidate, version: parseHdcVersion(stdout.toString('utf8')) }
495
+ return this.info
496
+ } catch {
497
+ // 继续找下一个
498
+ }
499
+ }
500
+ throw fail(
501
+ 'hdc-missing',
502
+ '未找到 hdc。安装 DevEco Studio / HarmonyOS SDK,或者把 hdcPath 指向 hdc 可执行文件。',
503
+ )
504
+ })()
505
+ try {
506
+ return await this._resolving
507
+ } finally {
508
+ this._resolving = null
509
+ }
510
+ }
511
+
512
+ async _run(args, options = {}) {
513
+ await this.resolve()
514
+ const result = await exec(this.bin, args, {
515
+ timeout: options.timeout || this.timeout,
516
+ maxBuffer: options.maxBuffer || MAX_BUFFER,
517
+ code: options.code || 'hdc-failed',
518
+ })
519
+ // hdc 的失败经常是 exit 0 + stdout 里的 [Fail],必须单独识别
520
+ const output = result.stdout.toString('utf8')
521
+ if (/\[Fail\]/i.test(output)) {
522
+ const line = output.split(/\r?\n/).find((item) => /\[Fail\]/i.test(item)) || ''
523
+ throw fail(options.failCode || 'hdc-failed', line.replace(/\[Fail\]/gi, '').trim() || 'hdc 执行失败')
524
+ }
525
+ return result
526
+ }
527
+
528
+ async _listTargets({ fresh = false } = {}) {
529
+ const now = Date.now()
530
+ if (!fresh && now - this._targetsCache.at < LIST_TTL) return this._targetsCache.list
531
+ let source
532
+ try {
533
+ const { stdout } = await this._run(['list', 'targets', '-v'], { timeout: this.timeout })
534
+ source = stdout.toString('utf8')
535
+ if (/usage|unknown option|not support/i.test(source)) source = ''
536
+ } catch (error) {
537
+ if (error.code === 'hdc-missing') throw error
538
+ source = ''
539
+ }
540
+ if (!source) {
541
+ const { stdout } = await this._run(['list', 'targets'], { timeout: this.timeout })
542
+ source = stdout.toString('utf8')
543
+ }
544
+ const list = parseHdcTargets(source)
545
+ this._targetsCache = { at: now, list }
546
+ return list
547
+ }
548
+
549
+ async _param(serial, key) {
550
+ const { stdout } = await this._run(['-t', serial, 'shell', 'param', 'get', key], {
551
+ timeout: this.timeout,
552
+ maxBuffer: 1024 * 1024,
553
+ failCode: 'hdc-param-failed',
554
+ })
555
+ return stdout.toString('utf8').trim()
556
+ }
557
+
558
+ /** 设备型号信息,5 分钟缓存;多个 param get 并行跑。 */
559
+ async profile(serial, options = {}) {
560
+ assertHdcSerial(serial)
561
+ const cached = this._profiles.get(serial)
562
+ const now = Date.now()
563
+ if (!options.fresh && cached && now - cached.at < PROFILE_TTL) return cached.value
564
+ const [model, name, brand] = await Promise.all([
565
+ this._param(serial, 'const.product.model'),
566
+ this._param(serial, 'const.product.name'),
567
+ this._param(serial, 'const.product.brand'),
568
+ ])
569
+ const value = { model, name, brand }
570
+ this._profiles.set(serial, { at: now, value })
571
+ return value
572
+ }
573
+
574
+ /**
575
+ * hdc 设备列表。
576
+ * @param {{fresh?: boolean, includeSimulators?: boolean, profiles?: boolean, profilesFresh?: boolean}} options
577
+ */
578
+ async targets(options = {}) {
579
+ const includeSimulators = options.includeSimulators === true
580
+ const base = await this._listTargets({ fresh: options.fresh })
581
+ if (options.profiles === false) {
582
+ return base.map((target) => this._device(target, null, false))
583
+ }
584
+ const rows = await Promise.all(
585
+ base.map(async (target) => {
586
+ let profile = null
587
+ if (target.state === 'device') {
588
+ try {
589
+ profile = await this.profile(target.serial, { fresh: options.profilesFresh })
590
+ } catch {
591
+ profile = null
592
+ }
593
+ }
594
+ return { target, profile }
595
+ }),
596
+ )
597
+ const devices = []
598
+ for (const row of rows) {
599
+ // param 读不到时,回环 TCP 目标按模拟器处理,避免模拟器在 hdc 列表里重复出现
600
+ const simulator = isEmulatorProfile(row.profile) || (!row.profile && isLoopbackSerial(row.target.serial))
601
+ if (simulator && !includeSimulators) continue
602
+ devices.push(this._device(row.target, row.profile, simulator))
603
+ }
604
+ return devices
605
+ }
606
+
607
+ /** 与 PhoneTransport 对齐的列表:默认不含本地模拟器。 */
608
+ async list(options = {}) {
609
+ return this.targets(options)
610
+ }
611
+
612
+ /** 任务板命名兼容:和 list() 等价。 */
613
+ async listTargets(options = {}) {
614
+ return this.targets(options)
615
+ }
616
+
617
+ _device(target, profile, simulator) {
618
+ const screen = this._screens.get(target.serial)
619
+ return {
620
+ serial: target.serial,
621
+ state: target.state === 'device' ? 'device' : 'offline',
622
+ model: (profile && (profile.name || profile.model)) || target.serial,
623
+ platform: 'harmony',
624
+ kind: simulator ? 'simulator' : target.connection === 'TCP' ? 'wifi' : 'usb',
625
+ width: screen ? screen.info.width : null,
626
+ height: screen ? screen.info.height : null,
627
+ transport: 'hdc',
628
+ connection: target.connection || null,
629
+ }
630
+ }
631
+
632
+ /** serial 必须出现在最新 target 列表里且已连接。 */
633
+ async assertReady(serial) {
634
+ assertHdcSerial(serial)
635
+ const targets = await this._listTargets({ fresh: true })
636
+ const target = targets.find((item) => item.serial === serial)
637
+ if (!target) throw fail('unknown-device', `设备 ${serial} 不在 hdc list targets 里`)
638
+ if (target.state !== 'device') throw fail('device-not-ready', `设备 ${serial} 当前状态是 offline`)
639
+ return target
640
+ }
641
+
642
+ /** 找本地模拟器的 hdc serial(供 HarmonyEmulator 的 key/text 使用)。 */
643
+ async emulatorSerial(options = {}) {
644
+ const targets = await this._listTargets({ fresh: options.fresh })
645
+ const local = targets.filter((target) => target.state === 'device' && isLoopbackSerial(target.serial))
646
+ if (local.length === 1) return local[0].serial
647
+ if (local.length > 1) {
648
+ const emulators = []
649
+ for (const target of local) {
650
+ try {
651
+ const profile = await this.profile(target.serial, { fresh: options.fresh })
652
+ if (isEmulatorProfile(profile)) emulators.push(target.serial)
653
+ } catch {
654
+ // 读不到 param 就跳过,交给下面的歧义报错
655
+ }
656
+ }
657
+ if (emulators.length === 1) return emulators[0]
658
+ throw fail(
659
+ 'ambiguous-emulator',
660
+ `有多个本地 hdc 目标,无法确定是哪个模拟器:${local.map((item) => item.serial).join(', ')};请在事件里显式指定 serial`,
661
+ )
662
+ }
663
+ const emulators = []
664
+ for (const target of targets) {
665
+ if (target.state !== 'device') continue
666
+ try {
667
+ const profile = await this.profile(target.serial, { fresh: options.fresh })
668
+ if (isEmulatorProfile(profile)) emulators.push(target.serial)
669
+ } catch {
670
+ // 忽略
671
+ }
672
+ }
673
+ if (emulators.length === 1) return emulators[0]
674
+ throw fail('hdc-serial-unknown', '没有找到模拟器的 hdc 目标,确认模拟器已启动并已连上 hdc')
675
+ }
676
+
677
+ _remotePath(extension) {
678
+ this._sequence += 1
679
+ return `/data/local/tmp/dsh-phone-${process.pid}-${this._sequence}.${extension}`
680
+ }
681
+
682
+ /** 截一帧 JPEG:设备上 snapshot_display → file recv → 读内存 → 清理远端/本地临时文件。 */
683
+ async shot(serial) {
684
+ await this.assertReady(serial)
685
+ const localDir = mkdtempSync(join(tmpdir(), 'dsh-phone-hos-'))
686
+ const remote = this._remotePath('jpeg')
687
+ try {
688
+ const { stdout } = await this._run(['-t', serial, 'shell', 'snapshot_display', '-f', remote], {
689
+ timeout: this.shotTimeout,
690
+ failCode: 'hdc-shot-failed',
691
+ })
692
+ const local = join(localDir, 'shot.jpeg')
693
+ await this._run(['-t', serial, 'file', 'recv', remote, local], {
694
+ timeout: this.transferTimeout,
695
+ failCode: 'hdc-transfer-failed',
696
+ })
697
+ const buffer = readScreenshot(local)
698
+ if (!isJpeg(buffer)) throw fail('bad-screenshot', 'hdc 返回的不是 JPEG 截图')
699
+ if (buffer.length < 4 || buffer[buffer.length - 2] !== 0xff || buffer[buffer.length - 1] !== 0xd9) {
700
+ throw fail('bad-screenshot', 'hdc 截图不完整(缺 JPEG EOI)')
701
+ }
702
+ const size = parseSnapshotSize(stdout.toString('utf8')) || parseJpegSize(buffer)
703
+ if (size) this._screens.set(serial, { at: Date.now(), info: size })
704
+ return buffer
705
+ } finally {
706
+ try {
707
+ await this._run(['-t', serial, 'shell', 'rm', '-f', remote], { timeout: 5000 })
708
+ } catch {
709
+ // 清理失败不影响截图结果
710
+ }
711
+ rmSync(localDir, { recursive: true, force: true })
712
+ }
713
+ }
714
+
715
+ /** 分辨率:优先从 snapshot_display 输出推断,取不到再回退到整张截图;缓存 5 分钟。 */
716
+ async screenInfo(serial, options = {}) {
717
+ await this.assertReady(serial)
718
+ const cached = this._screens.get(serial)
719
+ const now = Date.now()
720
+ if (!options.fresh && cached && now - cached.at < SCREEN_TTL) return cached.info
721
+ const remote = this._remotePath('jpeg')
722
+ let info = null
723
+ try {
724
+ const { stdout } = await this._run(['-t', serial, 'shell', 'snapshot_display', '-f', remote], {
725
+ timeout: this.shotTimeout,
726
+ failCode: 'hdc-shot-failed',
727
+ })
728
+ info = parseSnapshotSize(stdout.toString('utf8'))
729
+ } finally {
730
+ try {
731
+ await this._run(['-t', serial, 'shell', 'rm', '-f', remote], { timeout: 5000 })
732
+ } catch {
733
+ // 忽略清理失败
734
+ }
735
+ }
736
+ if (!info) info = parseJpegSize(await this.shot(serial))
737
+ if (!info) throw fail('bad-screen-info', '无法从 hdc 截图推断分辨率')
738
+ this._screens.set(serial, { at: Date.now(), info })
739
+ return info
740
+ }
741
+
742
+ /** 同一台设备的写操作串行执行,避免输入乱序。 */
743
+ async _locked(serial, task) {
744
+ const previous = this._locks.get(serial) || Promise.resolve()
745
+ const current = previous.then(task, task)
746
+ const guarded = current.catch(() => {})
747
+ this._locks.set(serial, guarded)
748
+ try {
749
+ return await current
750
+ } finally {
751
+ if (this._locks.get(serial) === guarded) this._locks.delete(serial)
752
+ }
753
+ }
754
+
755
+ async _ui(serial, args) {
756
+ await this._run(['-t', serial, 'shell', 'uitest', 'uiInput', ...args], {
757
+ timeout: this.timeout,
758
+ failCode: 'hdc-input-failed',
759
+ })
760
+ }
761
+
762
+ async _text(serial, event, value, forcePoint) {
763
+ if (hasPoint(event)) {
764
+ const x = clampInt(event.x, 0, 20000, 'x')
765
+ const y = clampInt(event.y, 0, 20000, 'y')
766
+ await this._ui(serial, ['inputText', String(x), String(y), value])
767
+ return { action: 'inputText', x, y }
768
+ }
769
+ if (forcePoint) throw fail('bad-coordinate', 'inputText 需要 x / y 坐标')
770
+ await this._ui(serial, ['text', value])
771
+ return { action: 'text' }
772
+ }
773
+
774
+ /** 点按 / 滑动 / 按键 / 文字。坐标是设备像素。 */
775
+ async input(serial, event = {}) {
776
+ await this.assertReady(serial)
777
+ const action = String(event.action || '')
778
+ return this._locked(serial, async () => {
779
+ switch (action) {
780
+ case 'tap': {
781
+ const x = clampInt(event.x, 0, 20000, 'x')
782
+ const y = clampInt(event.y, 0, 20000, 'y')
783
+ await this._ui(serial, ['click', String(x), String(y)])
784
+ return { action, x, y }
785
+ }
786
+ case 'swipe': {
787
+ const x1 = clampInt(event.x1, 0, 20000, 'x1')
788
+ const y1 = clampInt(event.y1, 0, 20000, 'y1')
789
+ const x2 = clampInt(event.x2, 0, 20000, 'x2')
790
+ const y2 = clampInt(event.y2, 0, 20000, 'y2')
791
+ const args = ['swipe', String(x1), String(y1), String(x2), String(y2)]
792
+ let velocity = null
793
+ if (event.velocity !== undefined && event.velocity !== null) {
794
+ velocity = clampInt(event.velocity, 200, 40000, 'velocity')
795
+ args.push(String(velocity))
796
+ }
797
+ await this._ui(serial, args)
798
+ return { action, x1, y1, x2, y2, velocity }
799
+ }
800
+ case 'key': {
801
+ const key = keyArgument(event.key)
802
+ await this._ui(serial, ['keyEvent', key])
803
+ return { action, key }
804
+ }
805
+ case 'text': {
806
+ return this._text(serial, event, textArgument(event), false)
807
+ }
808
+ case 'inputText': {
809
+ return this._text(serial, event, textArgument(event), true)
810
+ }
811
+ default:
812
+ throw fail('bad-action', `不支持的操作: ${action || '(empty)'}`)
813
+ }
814
+ })
815
+ }
816
+ }
817
+
818
+ /**
819
+ * DevEco 模拟器 CLI 封装。
820
+ * 截图是 PNG;点按/滑动/旋转/电源走 CLI;按键和文字借道 hdc(模拟器没有对应 CLI)。
821
+ */
822
+ export class HarmonyEmulator {
823
+ constructor(options = {}) {
824
+ this.configuredPath = text(options.path)
825
+ this.timeout = positive(options.timeout) || EMULATOR_TIMEOUT
826
+ this.shotTimeout = positive(options.shotTimeout) || SHOT_TIMEOUT
827
+ this.startTimeout = positive(options.startTimeout) || START_TIMEOUT
828
+ this.stopTimeout = positive(options.stopTimeout) || STOP_TIMEOUT
829
+ this.createTimeout = positive(options.createTimeout) || CREATE_TIMEOUT
830
+ this.removeTimeout = positive(options.removeTimeout) || REMOVE_TIMEOUT
831
+ this.configuredHdcSerial = text(options.hdcSerial)
832
+ this.hdc =
833
+ options.hdc && typeof options.hdc.input === 'function'
834
+ ? options.hdc
835
+ : new HarmonyHdc({ path: options.hdcPath, timeout: options.hdcTimeout })
836
+ this.bin = null
837
+ this.info = null
838
+ this._resolving = null
839
+ this._screens = new Map()
840
+ this._images = new Map()
841
+ this._locks = new Map()
842
+ this._emulatorSerial = null
843
+ this._emulatorSerialAt = 0
844
+ }
845
+
846
+ /** 找到可用的 Emulator CLI(配置值 → DevEco Studio.app)。 */
847
+ async resolve() {
848
+ if (this.info) return this.info
849
+ if (this._resolving) return this._resolving
850
+ this._resolving = (async () => {
851
+ for (const candidate of emulatorCandidates(this.configuredPath)) {
852
+ if (candidate.includes('/') && !existsSync(candidate)) continue
853
+ try {
854
+ const { stdout } = await exec(candidate, ['-version'], { timeout: 5000, code: 'emulator-missing' })
855
+ this.bin = candidate
856
+ this.info = { bin: candidate, version: parseEmulatorVersion(stdout.toString('utf8')) }
857
+ return this.info
858
+ } catch {
859
+ // 继续找下一个
860
+ }
861
+ }
862
+ throw fail(
863
+ 'emulator-missing',
864
+ '未找到 HarmonyOS 模拟器 CLI。安装 DevEco Studio,或者把 emulatorPath 指向 Contents/tools/emulator/Emulator。',
865
+ )
866
+ })()
867
+ try {
868
+ return await this._resolving
869
+ } finally {
870
+ this._resolving = null
871
+ }
872
+ }
873
+
874
+ async _run(args, options = {}) {
875
+ await this.resolve()
876
+ return exec(this.bin, args, {
877
+ timeout: options.timeout || this.timeout,
878
+ maxBuffer: options.maxBuffer || MAX_BUFFER,
879
+ code: options.code || 'emulator-failed',
880
+ })
881
+ }
882
+
883
+ /** 所有实例(含未运行),按 JSON 详情解析。 */
884
+ async list() {
885
+ const { stdout } = await this._run(['-list', '-details'], { timeout: this.timeout })
886
+ return parseEmulatorList(stdout.toString('utf8'))
887
+ }
888
+
889
+ /** 可下载 / 已下载镜像。CLI 会联网检查更新、单次约 1s,按设备类型缓存 5 分钟。 */
890
+ async imageList(options = {}) {
891
+ const deviceType = normalizeDeviceType(options.deviceType || 'phone')
892
+ const cached = this._images.get(deviceType)
893
+ const now = Date.now()
894
+ if (!options.fresh && cached && now - cached.at < IMAGE_TTL) return cached.value
895
+ const { stdout } = await this._run(['-imageList', '-deviceType', deviceType.toLowerCase()], {
896
+ timeout: this.timeout,
897
+ })
898
+ const images = parseImageList(stdout.toString('utf8'))
899
+ this._images.set(deviceType, { at: now, value: images })
900
+ return images
901
+ }
902
+
903
+ async find(name) {
904
+ const instance = normalizeEmulatorName(name)
905
+ const list = await this.list()
906
+ const device = list.find((item) => item.serial === instance)
907
+ if (!device) throw fail('unknown-emulator', `模拟器 ${instance} 不存在(Emulator -list 里没有)`)
908
+ return device
909
+ }
910
+
911
+ _assertRunning(device) {
912
+ if (device.state !== 'device') {
913
+ throw fail('emulator-not-running', `模拟器 ${device.serial} 未运行,先 start() 再操作`)
914
+ }
915
+ }
916
+
917
+ async _poll(name, predicate, timeout) {
918
+ const deadline = Date.now() + timeout
919
+ while (Date.now() < deadline) {
920
+ const list = await this.list()
921
+ const device = list.find((item) => item.serial === name)
922
+ if (device && predicate(device)) return device
923
+ await sleep(POLL_INTERVAL)
924
+ }
925
+ return null
926
+ }
927
+
928
+ async _pollGone(name, timeout) {
929
+ const deadline = Date.now() + timeout
930
+ while (Date.now() < deadline) {
931
+ const list = await this.list()
932
+ if (!list.some((item) => item.serial === name)) return true
933
+ await sleep(POLL_INTERVAL)
934
+ }
935
+ return false
936
+ }
937
+
938
+ /** 新建实例(镜像必须已下载)。 */
939
+ async create(options = {}) {
940
+ const name = normalizeEmulatorName(options.name)
941
+ const osVersion = text(options.osVersion)
942
+ if (!osVersion) throw fail('bad-os-version', 'osVersion 不能为空,例如 "HarmonyOS 7.0.0(26.0.0)"')
943
+ const deviceType = normalizeDeviceType(options.deviceType || 'phone')
944
+ const images = await this.imageList({ deviceType })
945
+ const image = images.find((item) => item.osVersion === osVersion)
946
+ if (!image) {
947
+ const available = images.map((item) => item.osVersion).slice(0, 5).join(' / ')
948
+ throw fail('image-missing', `没有 ${osVersion} 的 ${deviceType} 镜像;可用:${available || '(无)'}`)
949
+ }
950
+ if (!image.downloaded) {
951
+ throw fail('image-not-downloaded', `${osVersion} 镜像还没下载,先 Emulator -install 或换一个已下载版本`)
952
+ }
953
+ const existing = await this.list()
954
+ if (existing.some((item) => item.serial === name)) throw fail('emulator-exists', `模拟器 ${name} 已存在`)
955
+ const args = ['-create', name, '-deviceType', deviceType, '-osVersion', osVersion]
956
+ if (options.storage !== undefined && options.storage !== null) {
957
+ args.push('-storage', String(clampInt(options.storage, 2, 1023, 'storage')))
958
+ }
959
+ if (options.memory !== undefined && options.memory !== null) {
960
+ args.push('-memory', String(clampInt(options.memory, 2, 32, 'memory')))
961
+ }
962
+ if (typeof options.hotBoot === 'boolean') args.push('-hotBoot', String(options.hotBoot))
963
+ const { stdout } = await this._run(args, { timeout: this.createTimeout })
964
+ const created = await this._poll(name, () => true, Math.min(this.startTimeout, 15000))
965
+ if (!created) {
966
+ throw fail('create-failed', `创建命令已执行,但实例 ${name} 没出现在列表里:${firstLine(stdout) || '(无输出)'}`)
967
+ }
968
+ return created
969
+ }
970
+
971
+ /** 启动并等到 isRunning=true。已经在跑就直接返回。 */
972
+ async start(name) {
973
+ const instance = normalizeEmulatorName(name)
974
+ const existing = await this.find(instance)
975
+ if (existing.state === 'device') return existing
976
+ await this._run(['-start', instance], { timeout: this.startTimeout })
977
+ const started = await this._poll(instance, (device) => device.state === 'device', this.startTimeout)
978
+ if (!started) {
979
+ throw fail('start-timeout', `模拟器 ${instance} 在 ${Math.round(this.startTimeout / 1000)}s 内没有启动完成`)
980
+ }
981
+ return started
982
+ }
983
+
984
+ /** 停止并等到 isRunning=false。已经停止就直接返回。 */
985
+ async stop(name) {
986
+ const instance = normalizeEmulatorName(name)
987
+ const existing = await this.find(instance)
988
+ if (existing.state !== 'device') return existing
989
+ await this._run(['-stop', instance], { timeout: this.stopTimeout })
990
+ const stopped = await this._poll(instance, (device) => device.state !== 'device', this.stopTimeout)
991
+ if (!stopped) {
992
+ throw fail('stop-timeout', `模拟器 ${instance} 在 ${Math.round(this.stopTimeout / 1000)}s 内没有停下来`)
993
+ }
994
+ return stopped
995
+ }
996
+
997
+ /** 删除实例(要求先停止,避免误伤正在跑的画面)。 */
998
+ async remove(name) {
999
+ const instance = normalizeEmulatorName(name)
1000
+ const existing = await this.find(instance)
1001
+ if (existing.state === 'device') {
1002
+ throw fail('emulator-running', `模拟器 ${instance} 正在运行,先 stop() 再 remove()`)
1003
+ }
1004
+ await this._run(['-delete', instance, '-force'], { timeout: this.removeTimeout })
1005
+ const gone = await this._pollGone(instance, Math.min(this.removeTimeout, 15000))
1006
+ if (!gone) throw fail('remove-failed', `删除命令已执行,但实例 ${instance} 仍在列表里`)
1007
+ return { name: instance, removed: true }
1008
+ }
1009
+
1010
+ /** 任务板里的 delete() 命名兼容。 */
1011
+ async delete(name) {
1012
+ return this.remove(name)
1013
+ }
1014
+
1015
+ /** 截一帧 PNG(原始字节)。 */
1016
+ async shot(name) {
1017
+ const instance = normalizeEmulatorName(name)
1018
+ const device = await this.find(instance)
1019
+ this._assertRunning(device)
1020
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-phone-hos-'))
1021
+ try {
1022
+ const { stdout, stderr } = await this._run(
1023
+ ['-instance', instance, '-screenshot', '-screenshotPath', dir],
1024
+ { timeout: this.shotTimeout },
1025
+ )
1026
+ const file = await waitForScreenshot(dir, this.shotTimeout)
1027
+ if (!file) {
1028
+ const detail = firstLine(stdout) || firstLine(stderr) || '(无输出)'
1029
+ throw fail('bad-screenshot', `模拟器没有在 ${this.shotTimeout}ms 内产出 PNG 截图:${detail}`)
1030
+ }
1031
+ const buffer = readScreenshot(file)
1032
+ if (!isPng(buffer)) throw fail('bad-screenshot', '模拟器截图不是 PNG')
1033
+ if (buffer.length < 16 || buffer.toString('ascii', buffer.length - 8, buffer.length - 4) !== 'IEND') {
1034
+ throw fail('bad-screenshot', '模拟器截图不完整(缺 PNG IEND)')
1035
+ }
1036
+ const size = parsePngSize(buffer)
1037
+ if (size) this._screens.set(instance, { at: Date.now(), info: size })
1038
+ return buffer
1039
+ } finally {
1040
+ rmSync(dir, { recursive: true, force: true })
1041
+ }
1042
+ }
1043
+
1044
+ async _hdcSerial(options = {}) {
1045
+ const explicit = text(options.serial) || this.configuredHdcSerial
1046
+ if (explicit) {
1047
+ if (!HDC_SERIAL_RE.test(explicit)) throw fail('bad-serial', 'hdc 序列号不合法')
1048
+ return explicit
1049
+ }
1050
+ const now = Date.now()
1051
+ if (!options.fresh && this._emulatorSerial && now - this._emulatorSerialAt < 30_000) {
1052
+ return this._emulatorSerial
1053
+ }
1054
+ if (typeof this.hdc.emulatorSerial !== 'function') {
1055
+ throw fail('hdc-serial-unknown', '无法自动定位模拟器的 hdc 序列号:请在事件里显式指定 serial')
1056
+ }
1057
+ const serial = await this.hdc.emulatorSerial({ fresh: options.fresh })
1058
+ this._emulatorSerial = serial
1059
+ this._emulatorSerialAt = now
1060
+ return serial
1061
+ }
1062
+
1063
+ /** 点按 / 滑动 / 旋转 / 电源走 Emulator CLI;按键 / 文字走 hdc uitest。 */
1064
+ async input(name, event = {}) {
1065
+ const instance = normalizeEmulatorName(name)
1066
+ const device = await this.find(instance)
1067
+ this._assertRunning(device)
1068
+ const action = String(event.action || '')
1069
+ return this._locked(instance, async () => {
1070
+ switch (action) {
1071
+ case 'tap': {
1072
+ const x = clampInt(event.x, 0, 20000, 'x')
1073
+ const y = clampInt(event.y, 0, 20000, 'y')
1074
+ await this._run(['-instance', instance, '-click', String(x), String(y)], { timeout: this.timeout })
1075
+ return { action, x, y }
1076
+ }
1077
+ case 'swipe': {
1078
+ const x1 = clampInt(event.x1, 0, 20000, 'x1')
1079
+ const y1 = clampInt(event.y1, 0, 20000, 'y1')
1080
+ const x2 = clampInt(event.x2, 0, 20000, 'x2')
1081
+ const y2 = clampInt(event.y2, 0, 20000, 'y2')
1082
+ await this._run(['-instance', instance, '-slide', `${x1} ${y1} ${x2} ${y2}`], {
1083
+ timeout: this.timeout,
1084
+ })
1085
+ return { action, x1, y1, x2, y2 }
1086
+ }
1087
+ case 'rotate': {
1088
+ const direction = text(event.direction || 'right').toLowerCase()
1089
+ if (direction !== 'left' && direction !== 'right') {
1090
+ throw fail('bad-direction', '旋转方向只能是 left / right')
1091
+ }
1092
+ await this._run(['-instance', instance, '-rotation', direction], { timeout: this.timeout })
1093
+ return { action, direction }
1094
+ }
1095
+ case 'power': {
1096
+ await this._run(['-instance', instance, '-power'], { timeout: this.timeout })
1097
+ return { action }
1098
+ }
1099
+ case 'key':
1100
+ case 'text':
1101
+ case 'inputText': {
1102
+ const serial = await this._hdcSerial({ serial: event.serial })
1103
+ try {
1104
+ return await this.hdc.input(serial, event)
1105
+ } catch (error) {
1106
+ if (error.code !== 'unknown-device' && error.code !== 'device-not-ready') throw error
1107
+ this._emulatorSerial = null
1108
+ const retry = await this._hdcSerial({ fresh: true, serial: event.serial })
1109
+ return this.hdc.input(retry, event)
1110
+ }
1111
+ }
1112
+ default:
1113
+ throw fail('bad-action', `不支持的操作: ${action || '(empty)'}`)
1114
+ }
1115
+ })
1116
+ }
1117
+
1118
+ /** 分辨率:来自实例详情,缓存 5 分钟(折叠屏可能有双屏字段,取 single 再回退 double)。 */
1119
+ async screenInfo(name, options = {}) {
1120
+ const instance = normalizeEmulatorName(name)
1121
+ const cached = this._screens.get(instance)
1122
+ const now = Date.now()
1123
+ if (!options.fresh && cached && now - cached.at < SCREEN_TTL) return cached.info
1124
+ const device = await this.find(instance)
1125
+ if (!device.width || !device.height) {
1126
+ throw fail('bad-screen-info', `模拟器 ${instance} 没有上报分辨率`)
1127
+ }
1128
+ const info = { width: device.width, height: device.height }
1129
+ this._screens.set(instance, { at: now, info })
1130
+ return info
1131
+ }
1132
+
1133
+ async _locked(name, task) {
1134
+ const previous = this._locks.get(name) || Promise.resolve()
1135
+ const current = previous.then(task, task)
1136
+ const guarded = current.catch(() => {})
1137
+ this._locks.set(name, guarded)
1138
+ try {
1139
+ return await current
1140
+ } finally {
1141
+ if (this._locks.get(name) === guarded) this._locks.delete(name)
1142
+ }
1143
+ }
1144
+ }