@ponydev/dsh-phone 0.0.0-stage → 0.2.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/adb.js ADDED
@@ -0,0 +1,400 @@
1
+ // dsh-phone · adb 层
2
+ // 只做三件事:找 adb、跑 adb、把输出解析成结构化数据。
3
+ // 所有命令一律 execFile + 参数数组,不经过 shell;serial / 地址都先过白名单。
4
+ import { execFile } from 'node:child_process'
5
+ import { existsSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ /** serial 白名单:字母数字开头,允许 . _ : -,最长 128 */
10
+ export const SERIAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$/
11
+ /** adb connect 地址:host:port,host 只允许字母数字 . - _ : */
12
+ export const ADDRESS_RE = /^[A-Za-z0-9][A-Za-z0-9.\-_:]{0,127}:\d{1,5}$/
13
+ /** `input keyevent` 白名单 */
14
+ export const KEYS = Object.freeze({
15
+ back: '4',
16
+ home: '3',
17
+ recents: '187',
18
+ power: '26',
19
+ wakeup: '224',
20
+ menu: '82',
21
+ enter: '66',
22
+ })
23
+
24
+ const DEVICE_STATES = new Set([
25
+ 'device',
26
+ 'offline',
27
+ 'unauthorized',
28
+ 'authorizing',
29
+ 'recovering',
30
+ 'bootloader',
31
+ 'recovery',
32
+ 'sideload',
33
+ 'unknown',
34
+ ])
35
+
36
+ const DEFAULT_TIMEOUT = 8000
37
+ const SHOT_TIMEOUT = 10000
38
+ const DEVICES_TTL = 1500
39
+ const PLATFORM_TTL = 5 * 60 * 1000
40
+
41
+ function fail(code, message) {
42
+ const error = new Error(message)
43
+ error.code = code
44
+ return error
45
+ }
46
+
47
+ function firstLine(buffer) {
48
+ if (!buffer) return ''
49
+ const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
50
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] || ''
51
+ }
52
+
53
+ function exec(file, args, options = {}) {
54
+ const timeout = options.timeout || DEFAULT_TIMEOUT
55
+ const maxBuffer = options.maxBuffer || 32 * 1024 * 1024
56
+ return new Promise((resolve, reject) => {
57
+ execFile(file, args, { timeout, maxBuffer, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => {
58
+ if (error) {
59
+ const message = firstLine(stderr) || firstLine(stdout) || error.message || 'adb failed'
60
+ const failure = fail(options.code || 'adb-failed', message)
61
+ failure.stdout = stdout
62
+ failure.stderr = stderr
63
+ failure.exitCode = typeof error.code === 'number' ? error.code : undefined
64
+ failure.timedOut = error.killed === true
65
+ reject(failure)
66
+ return
67
+ }
68
+ resolve({ stdout, stderr })
69
+ })
70
+ })
71
+ }
72
+
73
+ function prettyModel(value) {
74
+ if (!value) return ''
75
+ return String(value).replace(/_/g, ' ').trim()
76
+ }
77
+
78
+ /** Tailscale 的 100.64.0.0/10(RFC 6598 CGNAT) */
79
+ function isCgnatSerial(serial) {
80
+ const match = /^(\d+)\.(\d+)\.\d+\.\d+:/.exec(serial)
81
+ if (!match) return false
82
+ const first = Number(match[1])
83
+ const second = Number(match[2])
84
+ return first === 100 && second >= 64 && second <= 127
85
+ }
86
+
87
+ /** 按 serial / adb 属性给设备标注连接方式,客户端只显示标签,不做分组。 */
88
+ export function classifyDevice(serial, props = {}) {
89
+ if (props.usb) return 'usb'
90
+ if (/^emulator-\d+$/.test(serial)) return 'emulator'
91
+ if (/^unicom-openclaw-|\.ts\.net/.test(serial)) return 'tailscale'
92
+ if (isCgnatSerial(serial)) return 'tailscale'
93
+ if (serial.includes(':')) return 'wifi'
94
+ return 'usb'
95
+ }
96
+
97
+ /** 解析 `adb devices -l`。纯函数,smoke 直接测它。 */
98
+ export function parseDevices(text) {
99
+ const devices = []
100
+ for (const raw of String(text || '').split(/\r?\n/)) {
101
+ const line = raw.trim()
102
+ if (!line) continue
103
+ if (line.startsWith('List of devices')) continue
104
+ if (line.startsWith('*') || line.startsWith('adb server') || line.startsWith('ADB server')) continue
105
+ const parts = line.split(/\s+/)
106
+ if (parts.length < 2) continue
107
+ const serial = parts[0]
108
+ const state = parts[1]
109
+ if (!SERIAL_RE.test(serial)) continue
110
+ if (!DEVICE_STATES.has(state)) continue
111
+ const props = {}
112
+ for (const pair of parts.slice(2)) {
113
+ const at = pair.indexOf(':')
114
+ if (at > 0) props[pair.slice(0, at)] = pair.slice(at + 1)
115
+ }
116
+ devices.push({
117
+ serial,
118
+ state,
119
+ kind: classifyDevice(serial, props),
120
+ model: prettyModel(props.model) || prettyModel(props.product) || serial,
121
+ product: props.product || null,
122
+ device: props.device || null,
123
+ transportId: props.transport_id || null,
124
+ })
125
+ }
126
+ return devices
127
+ }
128
+
129
+ /** 解析 `adb shell wm size`:优先 Override size(实际显示分辨率),否则 Physical size。 */
130
+ export function parseWmSize(text) {
131
+ const source = String(text || '')
132
+ const override = /Override size:\s*(\d{2,5})x(\d{2,5})/i.exec(source)
133
+ const physical = /Physical size:\s*(\d{2,5})x(\d{2,5})/i.exec(source)
134
+ const match = override || physical
135
+ if (!match) return null
136
+ return { width: Number(match[1]), height: Number(match[2]), overridden: Boolean(override) }
137
+ }
138
+
139
+ /** 解析 `adb shell getprop` 的 `[key]: [value]` 输出 */
140
+ export function parseGetprop(text) {
141
+ const props = {}
142
+ for (const line of String(text || '').split(/\r?\n/)) {
143
+ const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim())
144
+ if (match) props[match[1]] = match[2]
145
+ }
146
+ return props
147
+ }
148
+
149
+ /** 从 getprop 属性判断系统;没有 harmony 标记的一律当 Android。 */
150
+ export function platformFromProps(props) {
151
+ const brand = String(props['ro.product.brand'] || props['ro.product.manufacturer'] || '')
152
+ const harmonyKeys = ['ro.build.version.harmony', 'ro.build.version.harmonyos', 'hw_sc.build.platform.version']
153
+ for (const key of harmonyKeys) {
154
+ const value = props[key]
155
+ if (typeof value === 'string' && value.trim() && value.trim() !== 'unknown') return 'harmony'
156
+ }
157
+ if (/harmony/i.test(brand)) return 'harmony'
158
+ return 'android'
159
+ }
160
+
161
+ function parseVersion(buffer) {
162
+ const text = buffer ? buffer.toString('utf8') : ''
163
+ const match = /version\s+([0-9][0-9A-Za-z.\-]*)/.exec(text)
164
+ return match ? match[1] : null
165
+ }
166
+
167
+ /** adb 进程封装。一个实例同时服务所有设备,按 serial 串行化写操作。 */
168
+ export class Adb {
169
+ constructor(options = {}) {
170
+ this.configuredPath = typeof options.path === 'string' && options.path.trim() ? options.path.trim() : 'adb'
171
+ this.timeout = Number(options.timeout) > 0 ? Number(options.timeout) : DEFAULT_TIMEOUT
172
+ this.bin = null
173
+ this.info = null
174
+ this._resolving = null
175
+ this._devices = { at: 0, list: [] }
176
+ this._locks = new Map()
177
+ this._screens = new Map()
178
+ this._platforms = new Map()
179
+ }
180
+
181
+ /** 找到可用的 adb 可执行文件(配置值 → PATH → SDK 常见位置)。 */
182
+ async resolve() {
183
+ if (this.info) return this.info
184
+ if (this._resolving) return this._resolving
185
+ this._resolving = (async () => {
186
+ const candidates = []
187
+ if (this.configuredPath !== 'adb') candidates.push(this.configuredPath)
188
+ candidates.push('adb')
189
+ for (const env of ['ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_SDK']) {
190
+ const value = process.env[env]
191
+ if (value) candidates.push(join(value, 'platform-tools', 'adb'))
192
+ }
193
+ candidates.push(join(homedir(), 'Library', 'Android', 'sdk', 'platform-tools', 'adb'))
194
+ candidates.push('/opt/homebrew/bin/adb', '/usr/local/bin/adb')
195
+ const seen = new Set()
196
+ for (const candidate of candidates) {
197
+ if (seen.has(candidate)) continue
198
+ seen.add(candidate)
199
+ if (candidate.includes('/') && !existsSync(candidate)) continue
200
+ try {
201
+ const { stdout } = await exec(candidate, ['version'], { timeout: 5000 })
202
+ this.bin = candidate
203
+ this.info = {
204
+ bin: candidate,
205
+ version: parseVersion(stdout),
206
+ raw: stdout.toString('utf8').split(/\r?\n/).filter(Boolean).slice(0, 2),
207
+ }
208
+ return this.info
209
+ } catch {
210
+ // 继续找下一个
211
+ }
212
+ }
213
+ throw fail(
214
+ 'adb-missing',
215
+ '未找到 adb。安装 Android platform-tools,或在 cordis.patch.yml 里把 adbPath 指向 adb 可执行文件。',
216
+ )
217
+ })()
218
+ try {
219
+ return await this._resolving
220
+ } finally {
221
+ this._resolving = null
222
+ }
223
+ }
224
+
225
+ async _run(args, options = {}) {
226
+ await this.resolve()
227
+ return exec(this.bin, args, options)
228
+ }
229
+
230
+ /** `adb devices -l`,1.5s 内复用缓存。 */
231
+ async devices({ fresh = false } = {}) {
232
+ const now = Date.now()
233
+ if (!fresh && now - this._devices.at < DEVICES_TTL) {
234
+ return { devices: this._devices.list, at: this._devices.at }
235
+ }
236
+ const { stdout } = await this._run(['devices', '-l'], { timeout: 5000 })
237
+ const list = parseDevices(stdout.toString('utf8'))
238
+ await Promise.all(
239
+ list.map(async (device) => {
240
+ if (device.state !== 'device') {
241
+ device.platform = null
242
+ return
243
+ }
244
+ try {
245
+ device.platform = await Promise.race([
246
+ this.platform(device.serial),
247
+ new Promise((resolve) => setTimeout(() => resolve('android'), 1500)),
248
+ ])
249
+ } catch {
250
+ device.platform = 'android'
251
+ }
252
+ }),
253
+ )
254
+ this._devices = { at: now, list }
255
+ return { devices: list, at: now }
256
+ }
257
+
258
+ /** 设备必须出现在最新列表里且处于 device 状态,才允许执行后续操作。 */
259
+ async assertReady(serial) {
260
+ if (typeof serial !== 'string' || !SERIAL_RE.test(serial)) {
261
+ throw fail('bad-serial', '设备序列号不合法')
262
+ }
263
+ const { devices } = await this.devices({ fresh: true })
264
+ const device = devices.find((item) => item.serial === serial)
265
+ if (!device) throw fail('unknown-device', `设备 ${serial} 不在 adb devices 列表里`)
266
+ if (device.state === 'unauthorized') {
267
+ throw fail('device-unauthorized', `设备 ${device.model} 未授权:请在手机上点「允许 USB 调试」`)
268
+ }
269
+ if (device.state !== 'device') {
270
+ throw fail('device-not-ready', `设备 ${device.model} 当前状态是 ${device.state}`)
271
+ }
272
+ return device
273
+ }
274
+
275
+ /** 同一台设备的写操作串行执行,避免 input 乱序。 */
276
+ async _locked(serial, task) {
277
+ const previous = this._locks.get(serial) || Promise.resolve()
278
+ const current = previous.then(task, task)
279
+ const guarded = current.catch(() => {})
280
+ this._locks.set(serial, guarded)
281
+ try {
282
+ return await current
283
+ } finally {
284
+ if (this._locks.get(serial) === guarded) this._locks.delete(serial)
285
+ }
286
+ }
287
+
288
+ /** 系统平台(android / harmony),getprop 读一次缓存 5 分钟。 */
289
+ async platform(serial) {
290
+ const cached = this._platforms.get(serial)
291
+ const now = Date.now()
292
+ if (cached && now - cached.at < PLATFORM_TTL) return cached.platform
293
+ const { stdout } = await this._run(['-s', serial, 'shell', 'getprop'], {
294
+ timeout: 4000,
295
+ maxBuffer: 4 * 1024 * 1024,
296
+ })
297
+ const platform = platformFromProps(parseGetprop(stdout.toString('utf8')))
298
+ this._platforms.set(serial, { at: now, platform })
299
+ return platform
300
+ }
301
+
302
+ /** 设备物理分辨率(wm size),客户端用它把画面坐标换算成输入坐标;缓存 60s。 */
303
+ async screenInfo(serial) {
304
+ await this.assertReady(serial)
305
+ const cached = this._screens.get(serial)
306
+ const now = Date.now()
307
+ if (cached && now - cached.at < 60_000) return cached.info
308
+ const { stdout } = await this._run(['-s', serial, 'shell', 'wm', 'size'], { timeout: 5000 })
309
+ const info = parseWmSize(stdout.toString('utf8'))
310
+ if (!info) throw fail('bad-screen-info', '无法读取设备分辨率')
311
+ this._screens.set(serial, { at: now, info })
312
+ return info
313
+ }
314
+
315
+ async connect(address) {
316
+ if (typeof address !== 'string' || !ADDRESS_RE.test(address.trim())) {
317
+ throw fail('bad-address', '地址格式应为 host:port,例如 100.90.248.53:5555')
318
+ }
319
+ const { stdout } = await this._run(['connect', address.trim()], { timeout: 8000 })
320
+ this._devices.at = 0
321
+ return { address: address.trim(), output: stdout.toString('utf8').trim() }
322
+ }
323
+
324
+ async disconnect(serial) {
325
+ if (typeof serial !== 'string' || !SERIAL_RE.test(serial)) throw fail('bad-serial', '设备序列号不合法')
326
+ const { stdout } = await this._run(['disconnect', serial], { timeout: 8000 })
327
+ this._devices.at = 0
328
+ return { serial, output: stdout.toString('utf8').trim() }
329
+ }
330
+
331
+ /** 截一帧 PNG(原始字节)。 */
332
+ async shot(serial) {
333
+ await this.assertReady(serial)
334
+ const { stdout } = await this._run(['-s', serial, 'exec-out', 'screencap', '-p'], {
335
+ timeout: SHOT_TIMEOUT,
336
+ maxBuffer: 32 * 1024 * 1024,
337
+ })
338
+ if (stdout.length < 8 || stdout[0] !== 0x89 || stdout.toString('ascii', 1, 4) !== 'PNG') {
339
+ throw fail('bad-screenshot', '设备返回的不是 PNG 截图')
340
+ }
341
+ return stdout
342
+ }
343
+
344
+ /** 点按 / 滑动 / 按键 / 旋转。坐标是设备像素。 */
345
+ async input(serial, event = {}) {
346
+ await this.assertReady(serial)
347
+ const action = String(event.action || '')
348
+ return this._locked(serial, async () => {
349
+ switch (action) {
350
+ case 'tap': {
351
+ const x = clampInt(event.x, 0, 20000)
352
+ const y = clampInt(event.y, 0, 20000)
353
+ await this._run(['-s', serial, 'shell', 'input', 'tap', String(x), String(y)], { timeout: 5000 })
354
+ return { action, x, y }
355
+ }
356
+ case 'swipe': {
357
+ const x1 = clampInt(event.x1, 0, 20000)
358
+ const y1 = clampInt(event.y1, 0, 20000)
359
+ const x2 = clampInt(event.x2, 0, 20000)
360
+ const y2 = clampInt(event.y2, 0, 20000)
361
+ const duration = clampInt(event.duration, 50, 5000)
362
+ await this._run(
363
+ ['-s', serial, 'shell', 'input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(duration)],
364
+ { timeout: 8000 },
365
+ )
366
+ return { action, x1, y1, x2, y2, duration }
367
+ }
368
+ case 'key': {
369
+ const key = String(event.key || '')
370
+ const code = KEYS[key]
371
+ if (!code) throw fail('bad-key', `不支持的按键: ${key}`)
372
+ await this._run(['-s', serial, 'shell', 'input', 'keyevent', code], { timeout: 5000 })
373
+ return { action, key }
374
+ }
375
+ case 'rotate': {
376
+ const { stdout } = await this._run(['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation'], {
377
+ timeout: 5000,
378
+ })
379
+ const current = Number(stdout.toString('utf8').trim()) || 0
380
+ const next = (current + 1) % 4
381
+ await this._run(['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'], {
382
+ timeout: 5000,
383
+ })
384
+ await this._run(['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)], {
385
+ timeout: 5000,
386
+ })
387
+ return { action, rotation: next }
388
+ }
389
+ default:
390
+ throw fail('bad-action', `不支持的操作: ${action || '(empty)'}`)
391
+ }
392
+ })
393
+ }
394
+ }
395
+
396
+ function clampInt(value, min, max) {
397
+ const number = Math.round(Number(value))
398
+ if (!Number.isFinite(number)) throw fail('bad-coordinate', '坐标必须是数字')
399
+ return Math.min(max, Math.max(min, number))
400
+ }