dsh-android-tool 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +138 -2
- package/client.js +1753 -0
- package/cordis.patch.yml +28 -0
- package/icon.svg +7 -0
- package/index.js +74 -0
- package/lib/adb.js +422 -0
- package/lib/config-schema.js +41 -0
- package/lib/config.js +175 -0
- package/lib/logcat.js +405 -0
- package/lib/paths.js +20 -0
- package/lib/perf.js +225 -0
- package/lib/scrcpy-protocol.js +135 -0
- package/lib/scrcpy-stream.js +681 -0
- package/lib/scrcpy-window.js +98 -0
- package/lib/service.js +748 -0
- package/lib/tools.js +179 -0
- package/lib/webapi.js +187 -0
- package/lib/ws.js +187 -0
- package/package.json +67 -4
- package/scrcpy-win64-v5.0.1/AdbWinApi.dll +0 -0
- package/scrcpy-win64-v5.0.1/AdbWinUsbApi.dll +0 -0
- package/scrcpy-win64-v5.0.1/LICENSE.txt +204 -0
- package/scrcpy-win64-v5.0.1/adb.exe +0 -0
- package/scrcpy-win64-v5.0.1/scrcpy-server +0 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AndroidService:把 adb、logcat、性能、scrcpy 推流与独立窗口拼成一个门面。
|
|
3
|
+
*
|
|
4
|
+
* 对上层只暴露方法;所有方法都返回 `{ ok, ... }`,绝不抛。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { resolveDataRoot, resolveExecutablePaths } from './config.js'
|
|
12
|
+
import { layoutAt } from './paths.js'
|
|
13
|
+
import { Adb, pickDevice, resolveKeyCode } from './adb.js'
|
|
14
|
+
import { LogcatService } from './logcat.js'
|
|
15
|
+
import { PerfService } from './perf.js'
|
|
16
|
+
import { parseBattery } from './perf.js'
|
|
17
|
+
import { ScrcpyStream } from './scrcpy-stream.js'
|
|
18
|
+
import { ScrcpyWindow } from './scrcpy-window.js'
|
|
19
|
+
|
|
20
|
+
/** 把 `wm size` 输出解析成 { width, height }。 */
|
|
21
|
+
export function parseWmSize(text) {
|
|
22
|
+
const matches = [...String(text ?? '').matchAll(/(\d+)\s*x\s*(\d+)/giu)]
|
|
23
|
+
if (matches.length === 0) return undefined
|
|
24
|
+
const last = matches[matches.length - 1]
|
|
25
|
+
return { width: Number(last[1]), height: Number(last[2]) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 解析 df -k /data。 */
|
|
29
|
+
export function parseDf(text) {
|
|
30
|
+
const lines = String(text ?? '').trim().split(/\r?\n/u)
|
|
31
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
32
|
+
const parts = lines[index].trim().split(/\s+/u)
|
|
33
|
+
if (parts.length < 6) continue
|
|
34
|
+
const totalKb = Number(parts[1])
|
|
35
|
+
const usedKb = Number(parts[2])
|
|
36
|
+
const freeKb = Number(parts[3])
|
|
37
|
+
if ([totalKb, usedKb, freeKb].every((value) => Number.isFinite(value))) {
|
|
38
|
+
return { totalKb, usedKb, freeKb }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 解析 wifi 组合命令结果。 */
|
|
45
|
+
export function parseWifi(text) {
|
|
46
|
+
const source = String(text ?? '')
|
|
47
|
+
const enabledMatch = /Wi-?Fi is (enabled|disabled)/iu.exec(source)
|
|
48
|
+
const ssidMatch = /SSID:\s*("?)([^"\r\n]*)\1/iu.exec(source)
|
|
49
|
+
const ipMatch = /inet\s+(\d+\.\d+\.\d+\.\d+)/u.exec(source)
|
|
50
|
+
const airplaneMatch = /==AIR\r?\n(\d+)/u.exec(source)
|
|
51
|
+
return {
|
|
52
|
+
enabled: enabledMatch !== null ? /enabled/iu.test(enabledMatch[1]) : undefined,
|
|
53
|
+
ssid: ssidMatch !== null ? ssidMatch[2].trim() : undefined,
|
|
54
|
+
ip: ipMatch !== null ? ipMatch[1] : undefined,
|
|
55
|
+
airplaneMode: airplaneMatch !== null ? airplaneMatch[1] === '1' : undefined,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class AndroidService {
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {object} options.ctx
|
|
63
|
+
* @param {() => object} options.getConfig
|
|
64
|
+
* @param {string} options.pluginRoot
|
|
65
|
+
* @param {() => string} [options.getProfileDir]
|
|
66
|
+
* @param {() => string} [options.getHomeDir]
|
|
67
|
+
* @param {object} [options.logger]
|
|
68
|
+
*/
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.ctx = options.ctx
|
|
71
|
+
this.getConfig = options.getConfig
|
|
72
|
+
this.pluginRoot = options.pluginRoot
|
|
73
|
+
this.getProfileDir = options.getProfileDir ?? (() => process.env.DSH_PROFILE_DIR ?? '')
|
|
74
|
+
this.getHomeDir = options.getHomeDir ?? (() => homedir())
|
|
75
|
+
this.logger = options.logger
|
|
76
|
+
|
|
77
|
+
this.adb = new Adb({ getConfig: options.getConfig, pluginRoot: options.pluginRoot, logger: options.logger })
|
|
78
|
+
this.logcat = new LogcatService({ adb: this.adb, getConfig: options.getConfig, logger: options.logger })
|
|
79
|
+
this.perf = new PerfService({ adb: this.adb, logger: options.logger })
|
|
80
|
+
this.stream = new ScrcpyStream({ adb: this.adb, getConfig: options.getConfig, pluginRoot: options.pluginRoot, logger: options.logger })
|
|
81
|
+
this.window = new ScrcpyWindow({ adb: this.adb, getConfig: options.getConfig, logger: options.logger })
|
|
82
|
+
|
|
83
|
+
this.state = { deviceSerial: '', lastAddress: '' }
|
|
84
|
+
this.stateLoaded = undefined
|
|
85
|
+
this.agentActionAt = 0
|
|
86
|
+
this.statePollCount = 0
|
|
87
|
+
this.devicesCache = undefined
|
|
88
|
+
this.deviceSize = undefined
|
|
89
|
+
this.disposed = false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 取宿主服务。 */
|
|
93
|
+
get(name) {
|
|
94
|
+
try {
|
|
95
|
+
return this.ctx?.get?.(name)
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 数据根目录。 */
|
|
102
|
+
resolveDataRoot() {
|
|
103
|
+
return resolveDataRoot({
|
|
104
|
+
profileDir: String(this.getProfileDir() ?? '').trim(),
|
|
105
|
+
homeDir: this.getHomeDir(),
|
|
106
|
+
join,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 确保数据目录存在。 */
|
|
111
|
+
async ensureDirs() {
|
|
112
|
+
const { root } = this.resolveDataRoot()
|
|
113
|
+
const layout = layoutAt(root)
|
|
114
|
+
try {
|
|
115
|
+
await mkdir(layout.logsDir, { recursive: true })
|
|
116
|
+
return { ok: true, layout }
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 读运行期状态(只读一次)。 */
|
|
123
|
+
async loadState() {
|
|
124
|
+
if (this.stateLoaded !== undefined) return this.stateLoaded
|
|
125
|
+
const { root } = this.resolveDataRoot()
|
|
126
|
+
const layout = layoutAt(root)
|
|
127
|
+
this.stateLoaded = (async () => {
|
|
128
|
+
try {
|
|
129
|
+
const text = await readFile(layout.stateFile, 'utf8')
|
|
130
|
+
const parsed = JSON.parse(text)
|
|
131
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
132
|
+
this.state = {
|
|
133
|
+
deviceSerial: typeof parsed.deviceSerial === 'string' ? parsed.deviceSerial : '',
|
|
134
|
+
lastAddress: typeof parsed.lastAddress === 'string' ? parsed.lastAddress : '',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// 文件不存在或损坏:用默认值
|
|
139
|
+
}
|
|
140
|
+
return this.state
|
|
141
|
+
})()
|
|
142
|
+
return this.stateLoaded
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 原子写运行期状态。 */
|
|
146
|
+
async saveState() {
|
|
147
|
+
const { root } = this.resolveDataRoot()
|
|
148
|
+
const layout = layoutAt(root)
|
|
149
|
+
try {
|
|
150
|
+
await mkdir(root, { recursive: true })
|
|
151
|
+
const temp = `${layout.stateFile}.tmp`
|
|
152
|
+
await writeFile(temp, JSON.stringify(this.state, null, 2), 'utf8')
|
|
153
|
+
await rename(temp, layout.stateFile)
|
|
154
|
+
return { ok: true }
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 初始化:读状态 + 按需自动连接。 */
|
|
161
|
+
async init() {
|
|
162
|
+
await this.loadState()
|
|
163
|
+
const settings = this.getConfig()
|
|
164
|
+
if (settings.autoConnect === true && settings.wirelessHost.length > 0) {
|
|
165
|
+
void this.adb.connect(settings.wirelessHost, settings.wirelessPort).then((result) => {
|
|
166
|
+
if (result.ok === true) {
|
|
167
|
+
this.state.lastAddress = result.target ?? ''
|
|
168
|
+
void this.saveState()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** AI 用了工具 → 面板自动弹出。 */
|
|
175
|
+
markAgentAction() {
|
|
176
|
+
this.agentActionAt = Date.now()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 当前 adb 路径信息。 */
|
|
180
|
+
adbInfo() {
|
|
181
|
+
const paths = resolveExecutablePaths({
|
|
182
|
+
pluginRoot: this.pluginRoot,
|
|
183
|
+
adbPath: this.getConfig().adbPath,
|
|
184
|
+
scrcpyPath: this.getConfig().scrcpyPath,
|
|
185
|
+
join,
|
|
186
|
+
})
|
|
187
|
+
return {
|
|
188
|
+
path: paths.adbPath,
|
|
189
|
+
exists: existsSync(paths.adbPath),
|
|
190
|
+
scrcpyPath: paths.scrcpyPath,
|
|
191
|
+
scrcpyExists: existsSync(paths.scrcpyPath),
|
|
192
|
+
serverExists: existsSync(paths.serverPath),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 选一台设备(不抛)。 */
|
|
197
|
+
async resolveDevice(preferred) {
|
|
198
|
+
await this.loadState()
|
|
199
|
+
const wanted = String(preferred ?? this.state.deviceSerial ?? '').trim()
|
|
200
|
+
// 已经有一条活着的 scrcpy 连接时,直接认它,避免每次点击都 spawn 一次
|
|
201
|
+
// `adb devices`——那会平白给每次触控加 50–100ms。
|
|
202
|
+
if (this.stream.running === true && this.stream.serial.length > 0) {
|
|
203
|
+
if (wanted.length === 0 || wanted === this.stream.serial) {
|
|
204
|
+
return { ok: true, device: { serial: this.stream.serial, state: 'device' } }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const picked = await this.adb.selectDevice(wanted)
|
|
208
|
+
if (picked.ok === true && this.state.deviceSerial !== picked.device.serial) {
|
|
209
|
+
this.state.deviceSerial = picked.device.serial
|
|
210
|
+
void this.saveState()
|
|
211
|
+
}
|
|
212
|
+
return picked
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 面板/设置页状态。 */
|
|
216
|
+
async uiState() {
|
|
217
|
+
this.statePollCount += 1
|
|
218
|
+
await this.loadState()
|
|
219
|
+
// 面板/自动打开/设置页都会打这个接口;缓存 1.5 秒,避免每秒 spawn 多次 adb。
|
|
220
|
+
if (this.devicesCache === undefined || Date.now() - this.devicesCache.at > 1500) {
|
|
221
|
+
this.devicesCache = { at: Date.now(), result: await this.adb.devices() }
|
|
222
|
+
}
|
|
223
|
+
const listed = this.devicesCache.result
|
|
224
|
+
const picked = listed.ok === true ? pickDevice(listed.devices, this.state.deviceSerial) : { ok: false, error: listed.error }
|
|
225
|
+
const chosen = picked.ok === true ? picked.device : undefined
|
|
226
|
+
if (chosen !== undefined && this.state.deviceSerial !== chosen.serial) {
|
|
227
|
+
this.state.deviceSerial = chosen.serial
|
|
228
|
+
void this.saveState()
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
adb: this.adbInfo(),
|
|
233
|
+
devices: listed.devices ?? [],
|
|
234
|
+
selectedSerial: chosen?.serial ?? this.state.deviceSerial ?? '',
|
|
235
|
+
device: chosen ?? null,
|
|
236
|
+
deviceError: chosen === undefined ? picked.error : undefined,
|
|
237
|
+
stream: this.stream.status(),
|
|
238
|
+
window: this.window.status(),
|
|
239
|
+
logcat: {
|
|
240
|
+
running: this.logcat.running,
|
|
241
|
+
serial: this.logcat.serial,
|
|
242
|
+
lines: this.logcat.entries.length,
|
|
243
|
+
error: this.logcat.lastError || undefined,
|
|
244
|
+
},
|
|
245
|
+
previewFps: this.getConfig().previewFps,
|
|
246
|
+
statePollCount: this.statePollCount,
|
|
247
|
+
agentActionAt: this.agentActionAt,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 当前前台应用。 */
|
|
252
|
+
async currentApp(serial) {
|
|
253
|
+
const result = await this.adb.shell(
|
|
254
|
+
serial,
|
|
255
|
+
"dumpsys activity activities 2>/dev/null | grep -E 'mResumedActivity|topResumedActivity' | head -n 1",
|
|
256
|
+
{ timeoutMs: 8000 },
|
|
257
|
+
)
|
|
258
|
+
if (result.ok !== true) return undefined
|
|
259
|
+
const match = /([a-zA-Z][\w.]*\/[\w.$]+)/u.exec(result.stdout)
|
|
260
|
+
if (match === null) return undefined
|
|
261
|
+
return match[1]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 截图(返回 buffer + 可选图片附件)。 */
|
|
265
|
+
async screencapture(input = {}) {
|
|
266
|
+
const picked = await this.resolveDevice(input.serial)
|
|
267
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
268
|
+
const serial = picked.device.serial
|
|
269
|
+
const shot = await this.adb.screencap(serial)
|
|
270
|
+
if (shot.ok !== true) return { ok: false, error: shot.error, serial }
|
|
271
|
+
|
|
272
|
+
let savedPath
|
|
273
|
+
const explicit = String(input.savePath ?? '').trim()
|
|
274
|
+
if (explicit.length > 0) {
|
|
275
|
+
try {
|
|
276
|
+
await mkdir(dirname(explicit), { recursive: true })
|
|
277
|
+
await writeFile(explicit, shot.buffer)
|
|
278
|
+
savedPath = explicit
|
|
279
|
+
} catch {
|
|
280
|
+
savedPath = undefined
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let image
|
|
285
|
+
const attachments = this.get('attachments')
|
|
286
|
+
if (attachments !== undefined && typeof attachments.saveImage === 'function') {
|
|
287
|
+
try {
|
|
288
|
+
const ref = await attachments.saveImage({
|
|
289
|
+
data: shot.buffer,
|
|
290
|
+
mediaType: 'image/png',
|
|
291
|
+
name: `android-${serial.replace(/[^\w.-]/gu, '_')}.png`,
|
|
292
|
+
})
|
|
293
|
+
image = {
|
|
294
|
+
attachmentId: ref.attachmentId,
|
|
295
|
+
mediaType: ref.mediaType,
|
|
296
|
+
bytes: ref.bytes,
|
|
297
|
+
width: ref.width,
|
|
298
|
+
height: ref.height,
|
|
299
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
300
|
+
...(ref.originalDimensions === undefined ? {} : { originalDimensions: { ...ref.originalDimensions } }),
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
image = undefined
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (input.mark !== false) this.markAgentAction()
|
|
308
|
+
|
|
309
|
+
let current
|
|
310
|
+
if (input.includeState !== false) {
|
|
311
|
+
const app = await this.currentApp(serial)
|
|
312
|
+
const model = await this.adb.shell(serial, 'getprop ro.product.model', { timeoutMs: 5000 })
|
|
313
|
+
current = {
|
|
314
|
+
model: model.ok === true ? model.stdout.trim() : '',
|
|
315
|
+
activity: app ?? '',
|
|
316
|
+
package: app !== undefined ? app.split('/')[0] : '',
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
serial,
|
|
323
|
+
width: shot.width,
|
|
324
|
+
height: shot.height,
|
|
325
|
+
bytes: shot.buffer.length,
|
|
326
|
+
path: savedPath,
|
|
327
|
+
image,
|
|
328
|
+
...(current === undefined ? {} : current),
|
|
329
|
+
capturedAt: Date.now(),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 只取截图字节,给面板 <img> / 轮询用。 */
|
|
334
|
+
async screenshotBuffer(serial) {
|
|
335
|
+
const picked = await this.resolveDevice(serial)
|
|
336
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
337
|
+
const shot = await this.adb.screencap(picked.device.serial)
|
|
338
|
+
return shot
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 设备状态(电量 / WiFi / 存储 / 内存 / 屏幕 / 当前应用)。 */
|
|
342
|
+
async deviceState(input = {}) {
|
|
343
|
+
const picked = await this.resolveDevice(input.serial)
|
|
344
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
345
|
+
const serial = picked.device.serial
|
|
346
|
+
|
|
347
|
+
const propsResult = await this.adb.shell(serial, 'getprop ro.product.model; getprop ro.product.brand; getprop ro.build.version.release; getprop ro.build.version.sdk; getprop ro.product.cpu.abi', { timeoutMs: 8000 })
|
|
348
|
+
const props = propsResult.ok === true ? propsResult.stdout.split(/\r?\n/u) : []
|
|
349
|
+
const [model = '', brand = '', android = '', sdk = '', abi = ''] = props.map((line) => line.trim())
|
|
350
|
+
|
|
351
|
+
const batteryResult = await this.adb.shell(serial, 'dumpsys battery', { timeoutMs: 8000 })
|
|
352
|
+
const battery = parseBattery(batteryResult.stdout)
|
|
353
|
+
|
|
354
|
+
const wifiCommand = [
|
|
355
|
+
"echo '==WIFI'",
|
|
356
|
+
'cmd wifi status 2>/dev/null',
|
|
357
|
+
"echo '==IP'",
|
|
358
|
+
'ip -f inet addr show wlan0 2>/dev/null',
|
|
359
|
+
"echo '==AIR'",
|
|
360
|
+
'settings get global airplane_mode_on 2>/dev/null',
|
|
361
|
+
].join('; ')
|
|
362
|
+
const wifiResult = await this.adb.shell(serial, wifiCommand, { timeoutMs: 8000 })
|
|
363
|
+
const wifi = parseWifi(wifiResult.stdout)
|
|
364
|
+
|
|
365
|
+
const memResult = await this.adb.shell(serial, 'cat /proc/meminfo', { timeoutMs: 8000 })
|
|
366
|
+
const mem = parseMeminfoSafe(memResult.stdout)
|
|
367
|
+
|
|
368
|
+
const dfResult = await this.adb.shell(serial, 'df -k /data', { timeoutMs: 8000 })
|
|
369
|
+
const storage = parseDf(dfResult.stdout)
|
|
370
|
+
|
|
371
|
+
const sizeResult = await this.adb.shell(serial, 'wm size; wm density', { timeoutMs: 8000 })
|
|
372
|
+
const display = parseWmSize(sizeResult.stdout)
|
|
373
|
+
const densityMatch = /Physical density:\s*(\d+)/iu.exec(sizeResult.stdout)
|
|
374
|
+
|
|
375
|
+
const powerResult = await this.adb.shell(serial, "dumpsys power 2>/dev/null | grep -E 'mWakefulness=' | head -n 1", { timeoutMs: 8000 })
|
|
376
|
+
const wakeMatch = /mWakefulness=(\w+)/u.exec(powerResult.stdout)
|
|
377
|
+
|
|
378
|
+
const app = await this.currentApp(serial)
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
ok: true,
|
|
382
|
+
serial,
|
|
383
|
+
model,
|
|
384
|
+
brand,
|
|
385
|
+
android,
|
|
386
|
+
sdk,
|
|
387
|
+
abi,
|
|
388
|
+
battery,
|
|
389
|
+
wifi,
|
|
390
|
+
memory: mem,
|
|
391
|
+
storage,
|
|
392
|
+
display: display === undefined ? undefined : { ...display, density: densityMatch !== null ? Number(densityMatch[1]) : undefined },
|
|
393
|
+
screen: { wakefulness: wakeMatch !== null ? wakeMatch[1] : undefined },
|
|
394
|
+
current: app === undefined ? undefined : { activity: app, package: app.split('/')[0] },
|
|
395
|
+
sampledAt: Date.now(),
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 把设备物理坐标换算成 scrcpy 视频坐标。
|
|
401
|
+
* 面板点击本来就来自视频坐标(input.videoCoords=true),AI 工具截图看到的是
|
|
402
|
+
* 设备物理尺寸,所以只有工具调用需要换算。
|
|
403
|
+
*/
|
|
404
|
+
async toVideoPoint(serial, x, y) {
|
|
405
|
+
const width = this.stream.width || 0
|
|
406
|
+
const height = this.stream.height || 0
|
|
407
|
+
if (!(width > 0) || !(height > 0)) return undefined
|
|
408
|
+
if (this.deviceSize === undefined || Date.now() - this.deviceSize.at > 5000) {
|
|
409
|
+
const result = await this.adb.shell(serial, 'wm size', { timeoutMs: 5000 })
|
|
410
|
+
const parsed = parseWmSize(result.stdout) ?? { width: 1080, height: 1920 }
|
|
411
|
+
this.deviceSize = { at: Date.now(), ...parsed }
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
x: (Number(x) * width) / (this.deviceSize.width || width),
|
|
415
|
+
y: (Number(y) * height) / (this.deviceSize.height || height),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 模拟触屏 / 按键。优先走 scrcpy control 通道,延迟比 adb shell input 低很多。 */
|
|
420
|
+
async control(input = {}) {
|
|
421
|
+
const picked = await this.resolveDevice(input.serial)
|
|
422
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
423
|
+
const serial = picked.device.serial
|
|
424
|
+
const action = String(input.action ?? '').trim()
|
|
425
|
+
const stream = this.stream
|
|
426
|
+
const fast = stream.controlReady()
|
|
427
|
+
let result
|
|
428
|
+
let used = 'adb'
|
|
429
|
+
|
|
430
|
+
const point = async (x, y) => {
|
|
431
|
+
if (input.videoCoords === true) return { x: Number(x), y: Number(y) }
|
|
432
|
+
return (await this.toVideoPoint(serial, x, y)) ?? { x: Number(x), y: Number(y) }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (action === 'tap') {
|
|
436
|
+
if (fast) {
|
|
437
|
+
const p = await point(input.x, input.y)
|
|
438
|
+
result = (await stream.controlTap(p.x, p.y)) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
439
|
+
used = 'scrcpy'
|
|
440
|
+
} else {
|
|
441
|
+
result = await this.adb.tap(serial, input.x, input.y)
|
|
442
|
+
}
|
|
443
|
+
} else if (action === 'long_press') {
|
|
444
|
+
const ms = Number(input.durationMs) || 700
|
|
445
|
+
if (fast) {
|
|
446
|
+
const p = await point(input.x, input.y)
|
|
447
|
+
result = (await stream.controlSwipe(p.x, p.y, p.x, p.y, ms)) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
448
|
+
used = 'scrcpy'
|
|
449
|
+
} else {
|
|
450
|
+
result = await this.adb.swipe(serial, input.x, input.y, input.x, input.y, ms)
|
|
451
|
+
}
|
|
452
|
+
} else if (action === 'swipe') {
|
|
453
|
+
const duration = input.durationMs ?? 300
|
|
454
|
+
if (fast) {
|
|
455
|
+
const a = await point(input.x, input.y)
|
|
456
|
+
const b = await point(input.x2, input.y2)
|
|
457
|
+
result = (await stream.controlSwipe(a.x, a.y, b.x, b.y, duration)) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
458
|
+
used = 'scrcpy'
|
|
459
|
+
} else {
|
|
460
|
+
result = await this.adb.swipe(serial, input.x, input.y, input.x2, input.y2, duration)
|
|
461
|
+
}
|
|
462
|
+
} else if (action === 'key') {
|
|
463
|
+
const code = resolveKeyCode(input.key)
|
|
464
|
+
if (fast && code !== undefined) {
|
|
465
|
+
result = (await stream.controlKey(code)) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
466
|
+
used = 'scrcpy'
|
|
467
|
+
} else {
|
|
468
|
+
result = await this.adb.keyevent(serial, input.key)
|
|
469
|
+
}
|
|
470
|
+
} else if (action === 'text') {
|
|
471
|
+
if (fast) {
|
|
472
|
+
result = stream.controlText(input.text) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
473
|
+
used = 'scrcpy'
|
|
474
|
+
} else {
|
|
475
|
+
result = await this.adb.inputText(serial, input.text)
|
|
476
|
+
}
|
|
477
|
+
} else if (action === 'scroll') {
|
|
478
|
+
const direction = String(input.direction ?? 'down')
|
|
479
|
+
const amount = Math.max(1, (Number(input.amount) || 400) / 200)
|
|
480
|
+
if (fast) {
|
|
481
|
+
result = stream.controlScroll(direction, amount) ? { ok: true } : { ok: false, error: 'scrcpy control 写入失败' }
|
|
482
|
+
used = 'scrcpy'
|
|
483
|
+
} else {
|
|
484
|
+
const size = parseWmSize((await this.adb.shell(serial, 'wm size', { timeoutMs: 5000 })).stdout) ?? { width: 1080, height: 1920 }
|
|
485
|
+
const pixels = Math.max(50, Number(input.amount) || 400)
|
|
486
|
+
const cx = Math.round(size.width / 2)
|
|
487
|
+
const cy = Math.round(size.height / 2)
|
|
488
|
+
if (direction === 'up') result = await this.adb.swipe(serial, cx, cy - pixels, cx, cy + pixels, 250)
|
|
489
|
+
else if (direction === 'down') result = await this.adb.swipe(serial, cx, cy + pixels, cx, cy - pixels, 250)
|
|
490
|
+
else if (direction === 'left') result = await this.adb.swipe(serial, cx - pixels, cy, cx + pixels, cy, 250)
|
|
491
|
+
else result = await this.adb.swipe(serial, cx + pixels, cy, cx - pixels, cy, 250)
|
|
492
|
+
}
|
|
493
|
+
} else if (action === 'wake') {
|
|
494
|
+
if (fast && stream.controlPower(true)) {
|
|
495
|
+
result = { ok: true }
|
|
496
|
+
used = 'scrcpy'
|
|
497
|
+
} else {
|
|
498
|
+
result = await this.adb.keyevent(serial, 'wakeup')
|
|
499
|
+
}
|
|
500
|
+
} else if (action === 'open_app') {
|
|
501
|
+
const pkg = String(input.package ?? '').trim()
|
|
502
|
+
if (pkg.length === 0) return { ok: false, error: 'open_app 需要 package 参数。' }
|
|
503
|
+
result = await this.adb.shell(serial, `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { timeoutMs: 15000 })
|
|
504
|
+
} else {
|
|
505
|
+
return { ok: false, error: `不支持的 action:${action || '(空)'}` }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (result.ok !== true) return { ok: false, error: result.error, serial, action }
|
|
509
|
+
this.markAgentAction()
|
|
510
|
+
|
|
511
|
+
if (input.capture === false) {
|
|
512
|
+
return { ok: true, serial, action, via: used }
|
|
513
|
+
}
|
|
514
|
+
const shot = await this.screencapture({ serial, mark: true, includeState: true })
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
serial,
|
|
518
|
+
action,
|
|
519
|
+
via: used,
|
|
520
|
+
...(shot.ok === true
|
|
521
|
+
? {
|
|
522
|
+
width: shot.width,
|
|
523
|
+
height: shot.height,
|
|
524
|
+
image: shot.image,
|
|
525
|
+
model: shot.model,
|
|
526
|
+
package: shot.package,
|
|
527
|
+
activity: shot.activity,
|
|
528
|
+
}
|
|
529
|
+
: { screenshotError: shot.error }),
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 任意 adb 命令(最后兜底)。 */
|
|
534
|
+
async adbCommand(input = {}) {
|
|
535
|
+
const mode = String(input.mode ?? 'shell') === 'adb' ? 'adb' : 'shell'
|
|
536
|
+
const command = String(input.command ?? '').trim()
|
|
537
|
+
if (command.length === 0) return { ok: false, error: '命令为空。' }
|
|
538
|
+
const timeoutMs = Math.max(1000, Math.min(120000, Number(input.timeoutMs) || 20000))
|
|
539
|
+
|
|
540
|
+
let result
|
|
541
|
+
if (mode === 'shell') {
|
|
542
|
+
const picked = await this.resolveDevice(input.serial)
|
|
543
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
544
|
+
result = await this.adb.run(['shell', command], { serial: picked.device.serial, timeoutMs })
|
|
545
|
+
} else {
|
|
546
|
+
const args = splitArgsLoose(command)
|
|
547
|
+
const serial = String(input.serial ?? '').trim()
|
|
548
|
+
result = await this.adb.run(args, { serial, timeoutMs })
|
|
549
|
+
}
|
|
550
|
+
this.markAgentAction()
|
|
551
|
+
return {
|
|
552
|
+
ok: result.ok === true,
|
|
553
|
+
mode,
|
|
554
|
+
code: result.code,
|
|
555
|
+
stdout: result.stdout,
|
|
556
|
+
stderr: result.stderr,
|
|
557
|
+
truncated: result.truncated === true,
|
|
558
|
+
elapsedMs: result.elapsedMs,
|
|
559
|
+
error: result.ok === true ? undefined : result.error,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** 设置页 / 面板按钮。 */
|
|
564
|
+
async humanCommand(body = {}) {
|
|
565
|
+
await this.loadState()
|
|
566
|
+
const action = String(body.action ?? '').trim()
|
|
567
|
+
if (action === 'testAdb') return this.adb.version()
|
|
568
|
+
if (action === 'refreshDevices') return this.adb.devices()
|
|
569
|
+
if (action === 'select') {
|
|
570
|
+
this.state.deviceSerial = String(body.serial ?? '').trim()
|
|
571
|
+
const saved = await this.saveState()
|
|
572
|
+
return { ok: saved.ok === true, selectedSerial: this.state.deviceSerial, error: saved.error }
|
|
573
|
+
}
|
|
574
|
+
if (action === 'connect') {
|
|
575
|
+
const result = await this.adb.connect(body.host, body.port)
|
|
576
|
+
if (result.ok === true) {
|
|
577
|
+
this.state.lastAddress = result.target ?? ''
|
|
578
|
+
void this.saveState()
|
|
579
|
+
}
|
|
580
|
+
return result
|
|
581
|
+
}
|
|
582
|
+
if (action === 'pair') return this.adb.pair(body.host, body.port, body.code)
|
|
583
|
+
if (action === 'disconnect') return this.adb.disconnect(body.host, body.port)
|
|
584
|
+
if (action === 'enableTcpip') {
|
|
585
|
+
const picked = await this.resolveDevice(body.serial)
|
|
586
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
587
|
+
return this.adb.enableTcpip(picked.device.serial, body.port)
|
|
588
|
+
}
|
|
589
|
+
if (action === 'volume') {
|
|
590
|
+
return this.control({
|
|
591
|
+
action: 'key',
|
|
592
|
+
key: Number(body.delta) >= 0 ? 'volume_up' : 'volume_down',
|
|
593
|
+
serial: body.serial,
|
|
594
|
+
capture: false,
|
|
595
|
+
videoCoords: true,
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
if (action === 'key') {
|
|
599
|
+
return this.control({ action: 'key', key: body.key, serial: body.serial, capture: false, videoCoords: true })
|
|
600
|
+
}
|
|
601
|
+
if (action === 'tap' || action === 'swipe') {
|
|
602
|
+
return this.control({ ...body, action, capture: false, videoCoords: true })
|
|
603
|
+
}
|
|
604
|
+
if (action === 'startScrcpy') {
|
|
605
|
+
const picked = await this.resolveDevice(body.serial)
|
|
606
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
607
|
+
return this.window.launch(picked.device.serial)
|
|
608
|
+
}
|
|
609
|
+
if (action === 'stopScrcpy') return this.window.stop()
|
|
610
|
+
if (action === 'startVideo') {
|
|
611
|
+
const picked = await this.resolveDevice(body.serial)
|
|
612
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
613
|
+
return this.stream.start(picked.device.serial)
|
|
614
|
+
}
|
|
615
|
+
if (action === 'stopVideo') return this.stream.stop()
|
|
616
|
+
if (action === 'logcatStart') {
|
|
617
|
+
const picked = await this.resolveDevice(body.serial)
|
|
618
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
619
|
+
return this.logcat.ensure(picked.device.serial)
|
|
620
|
+
}
|
|
621
|
+
if (action === 'logcatStop') {
|
|
622
|
+
this.logcat.stop()
|
|
623
|
+
return { ok: true }
|
|
624
|
+
}
|
|
625
|
+
return { ok: false, error: `未知 action:${action || '(空)'}` }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** 读 logcat。 */
|
|
629
|
+
async logcatRead(options = {}) {
|
|
630
|
+
const picked = await this.resolveDevice(options.serial)
|
|
631
|
+
if (picked.ok !== true) return { ok: false, error: picked.error, entries: [], cursor: 0 }
|
|
632
|
+
return this.logcat.read({ ...options, serial: picked.device.serial })
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** 运行中 / 已安装包名。 */
|
|
636
|
+
async packages(options = {}) {
|
|
637
|
+
const picked = await this.resolveDevice(options.serial)
|
|
638
|
+
if (picked.ok !== true) return { ok: false, error: picked.error, running: [], installed: [], foreground: '' }
|
|
639
|
+
return this.logcat.scanPackages(picked.device.serial)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 性能。 */
|
|
643
|
+
async perfSample(options = {}) {
|
|
644
|
+
const picked = await this.resolveDevice(options.serial)
|
|
645
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
646
|
+
const pkg = String(options.pkg ?? '').trim() || (await this.currentApp(picked.device.serial))?.split('/')[0] || ''
|
|
647
|
+
return this.perf.sample(picked.device.serial, pkg)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** 导出 logcat 到文件。 */
|
|
651
|
+
async exportLogcat(filters = {}) {
|
|
652
|
+
const picked = await this.resolveDevice(filters.serial)
|
|
653
|
+
if (picked.ok !== true) return { ok: false, error: picked.error }
|
|
654
|
+
await this.logcat.ensure(picked.device.serial)
|
|
655
|
+
await this.logcat.ensurePidMap(picked.device.serial)
|
|
656
|
+
const text = this.logcat.exportText(filters)
|
|
657
|
+
const dirs = await this.ensureDirs()
|
|
658
|
+
if (dirs.ok !== true) return { ok: false, error: dirs.error }
|
|
659
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-')
|
|
660
|
+
const filename = `logcat-${stamp}.txt`
|
|
661
|
+
const path = join(dirs.layout.logsDir, filename)
|
|
662
|
+
try {
|
|
663
|
+
await writeFile(path, text, 'utf8')
|
|
664
|
+
return { ok: true, path, filename, bytes: Buffer.byteLength(text), lines: text.length === 0 ? 0 : text.trimEnd().split('\n').length, serial: picked.device.serial }
|
|
665
|
+
} catch (error) {
|
|
666
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** 当前 logcat 文本(下载用,不落盘)。 */
|
|
671
|
+
async exportLogcatText(filters = {}) {
|
|
672
|
+
const picked = await this.resolveDevice(filters.serial)
|
|
673
|
+
if (picked.ok !== true) return { ok: false, error: picked.error, text: '' }
|
|
674
|
+
await this.logcat.ensure(picked.device.serial)
|
|
675
|
+
await this.logcat.ensurePidMap(picked.device.serial)
|
|
676
|
+
return { ok: true, text: this.logcat.exportText(filters), serial: picked.device.serial }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** scrcpy 推流。 */
|
|
680
|
+
startVideo(serial) {
|
|
681
|
+
return this.stream.start(serial)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
stopVideo() {
|
|
685
|
+
return this.stream.stop()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
videoStatus() {
|
|
689
|
+
return this.stream.status()
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
handleVideoUpgrade(req, socket, head) {
|
|
693
|
+
return this.stream.handleUpgrade(req, socket, head)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** 插件卸载。 */
|
|
697
|
+
dispose() {
|
|
698
|
+
if (this.disposed) return
|
|
699
|
+
this.disposed = true
|
|
700
|
+
this.window.dispose()
|
|
701
|
+
this.logcat.dispose()
|
|
702
|
+
this.stream.dispose()
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 避免循环 import:本文件已 import parseMeminfo 的兄弟函数,这里直接用。 */
|
|
707
|
+
function parseMeminfoSafe(text) {
|
|
708
|
+
const values = {}
|
|
709
|
+
for (const line of String(text ?? '').split(/\r?\n/u)) {
|
|
710
|
+
const match = /^(MemTotal|MemFree|MemAvailable):\s+(\d+)/u.exec(line.trim())
|
|
711
|
+
if (match !== null) values[match[1]] = Number(match[2])
|
|
712
|
+
}
|
|
713
|
+
const totalKb = values.MemTotal ?? 0
|
|
714
|
+
const availableKb = values.MemAvailable ?? values.MemFree ?? 0
|
|
715
|
+
return {
|
|
716
|
+
totalMb: Math.round(totalKb / 1024),
|
|
717
|
+
usedMb: Math.round(Math.max(0, totalKb - availableKb) / 1024),
|
|
718
|
+
availableMb: Math.round(availableKb / 1024),
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 宽松拆 adb 参数。 */
|
|
723
|
+
function splitArgsLoose(text) {
|
|
724
|
+
const out = []
|
|
725
|
+
let current = ''
|
|
726
|
+
let quote = ''
|
|
727
|
+
for (const char of String(text ?? '')) {
|
|
728
|
+
if (quote !== '') {
|
|
729
|
+
if (char === quote) quote = ''
|
|
730
|
+
else current += char
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
if (char === '"' || char === "'") {
|
|
734
|
+
quote = char
|
|
735
|
+
continue
|
|
736
|
+
}
|
|
737
|
+
if (/\s/u.test(char)) {
|
|
738
|
+
if (current.length > 0) {
|
|
739
|
+
out.push(current)
|
|
740
|
+
current = ''
|
|
741
|
+
}
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
current += char
|
|
745
|
+
}
|
|
746
|
+
if (current.length > 0) out.push(current)
|
|
747
|
+
return out
|
|
748
|
+
}
|