@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/AGENTS.md +4 -1
- package/CHANGELOG.md +15 -0
- package/README.md +23 -8
- package/cordis.patch.yml +4 -0
- package/docs/DESIGN.md +20 -0
- package/lib/client.js +361 -3
- package/lib/hos.js +1144 -0
- package/lib/index.js +171 -13
- package/lib/ios.js +327 -0
- package/package.json +10 -6
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
|
+
}
|