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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# dsh-android-tool 的挂载层。
|
|
2
|
+
#
|
|
3
|
+
# 只插入 host 行:AndroidService + 四个 AI 工具 + /x-android-tool 的 HTTP/WS 接口。
|
|
4
|
+
# 浏览器半边(侧边栏「安卓设备」与设置里的「安卓调试」)随 dsh.client 一起加载,
|
|
5
|
+
# 不在这里声明。
|
|
6
|
+
#
|
|
7
|
+
# 配置项一律给默认值:
|
|
8
|
+
# - adbPath / scrcpyPath 留空 = 自动使用本插件目录下 scrcpy-win64-v5.0.1 里的
|
|
9
|
+
# adb.exe / scrcpy.exe;
|
|
10
|
+
# - wirelessHost 留空 = 还没配置无线调试,插件正常运行,只是连不上设备;
|
|
11
|
+
# 用户在「设置 → 安卓调试」里改,写入后立即生效(全部字段都是 volatile)。
|
|
12
|
+
- insert:
|
|
13
|
+
- id: dsh-android-tool
|
|
14
|
+
name: dsh-android-tool
|
|
15
|
+
config:
|
|
16
|
+
adbPath: ''
|
|
17
|
+
scrcpyPath: ''
|
|
18
|
+
wirelessHost: ''
|
|
19
|
+
wirelessPort: 5555
|
|
20
|
+
pairingPort: 0
|
|
21
|
+
autoConnect: false
|
|
22
|
+
videoMaxSize: 720
|
|
23
|
+
videoBitRate: 2500000
|
|
24
|
+
videoFps: 30
|
|
25
|
+
previewFps: 3
|
|
26
|
+
logcatMaxLines: 5000
|
|
27
|
+
adbTimeoutMs: 15000
|
|
28
|
+
scrcpyExtraArgs: ''
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="16" y="4" width="32" height="56" rx="7" fill="none" stroke="currentColor" stroke-width="3" opacity="0.9"/>
|
|
3
|
+
<rect x="20" y="9" width="24" height="42" rx="3" fill="currentColor" opacity="0.16"/>
|
|
4
|
+
<circle cx="32" cy="55" r="2.6" fill="currentColor" opacity="0.75"/>
|
|
5
|
+
<path d="M27 19h10M27 23h10M27 27h7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" opacity="0.85"/>
|
|
6
|
+
<path d="M24 33l-5 3 5 3M40 33l5 3-5 3" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-android-tool —— 把 Android 设备接进 DSH(host 半边)。
|
|
3
|
+
*
|
|
4
|
+
* 这一层做四件事:
|
|
5
|
+
* 1. 构造 AndroidService(adb / logcat / 性能 / scrcpy 推流与独立窗口);
|
|
6
|
+
* 2. 注册 android_view / android_state / android_control / android_adb 四个工具;
|
|
7
|
+
* 3. 注册 /x-android-tool 的 HTTP 与 WebSocket 接口(侧边栏与设置页用);
|
|
8
|
+
* 4. 卸载时关掉本插件启动的进程。
|
|
9
|
+
*
|
|
10
|
+
* 插件加载本身不 spawn 任何进程,第一次访问设备时才启动。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { snapshotConfig } from './lib/config.js'
|
|
14
|
+
import { Config } from './lib/config-schema.js'
|
|
15
|
+
import { pluginRootFrom } from './lib/paths.js'
|
|
16
|
+
import { AndroidService } from './lib/service.js'
|
|
17
|
+
import { buildTools } from './lib/tools.js'
|
|
18
|
+
import { registerWebRoutes } from './lib/webapi.js'
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-android-tool'
|
|
21
|
+
|
|
22
|
+
/** `tools` 是硬依赖:没有工具注册表,这个插件就没有意义。 */
|
|
23
|
+
export const inject = ['tools']
|
|
24
|
+
|
|
25
|
+
export { Config }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} ctx - host 作用域上下文
|
|
29
|
+
* @param {object} config - 运行期配置(volatile 字段需实时读取)
|
|
30
|
+
*/
|
|
31
|
+
export function apply(ctx, config) {
|
|
32
|
+
const logger = ctx.logger
|
|
33
|
+
const pluginRoot = pluginRootFrom(import.meta.url)
|
|
34
|
+
|
|
35
|
+
const service = new AndroidService({
|
|
36
|
+
ctx,
|
|
37
|
+
getConfig: () => snapshotConfig(config),
|
|
38
|
+
pluginRoot,
|
|
39
|
+
getProfileDir: () => process.env.DSH_PROFILE_DIR ?? '',
|
|
40
|
+
logger,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
ctx.provide('androidTool', service)
|
|
44
|
+
|
|
45
|
+
// ── 工具注册(host 层,所有会话可见)──────────────────────────────────────
|
|
46
|
+
for (const definition of buildTools({ service })) {
|
|
47
|
+
ctx.effect(() => ctx.tools.register(definition), `dsh-android-tool: ${definition.name}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 面板 / 设置页接口 ─────────────────────────────────────────────────────
|
|
51
|
+
ctx.inject(['webServer'], (scope) => {
|
|
52
|
+
scope.effect(
|
|
53
|
+
() => registerWebRoutes(scope.webServer, { service, logger }),
|
|
54
|
+
'dsh-android-tool: console routes',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ── 拆卸 ───────────────────────────────────────────────────────────────────
|
|
59
|
+
ctx.effect(
|
|
60
|
+
() => () => {
|
|
61
|
+
service.dispose()
|
|
62
|
+
},
|
|
63
|
+
'dsh-android-tool: disposal',
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
void service.init().catch((error) => {
|
|
67
|
+
logger?.warn?.(`dsh-android-tool: 初始化失败 ${error instanceof Error ? error.message : String(error)}`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const settings = snapshotConfig(config)
|
|
71
|
+
logger?.info?.(
|
|
72
|
+
`dsh-android-tool: 已加载(adb: ${settings.adbPath || '随包默认'} · 无线: ${settings.wirelessHost || '未配置'})`,
|
|
73
|
+
)
|
|
74
|
+
}
|
package/lib/adb.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adb 执行器:设备发现、无线调试、截图、shell 与按键。
|
|
3
|
+
*
|
|
4
|
+
* 约束:
|
|
5
|
+
* - 永不抛;所有失败都变成 `{ ok:false, error }`。
|
|
6
|
+
* - 每个命令都带 `-s <serial>`,避免多设备串线。
|
|
7
|
+
* - 超时用 taskkill /T /F(Windows)或 SIGKILL,避免 adb 卡死拖着界面。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { resolveExecutablePaths } from './config.js'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** 文本输出上限。 */
|
|
16
|
+
const TEXT_MAX_BYTES = 4 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
/** 截图上限。 */
|
|
19
|
+
const IMAGE_MAX_BYTES = 32 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
/** 常用按键名 → Android keycode。 */
|
|
22
|
+
export const KEY_CODES = {
|
|
23
|
+
back: 4,
|
|
24
|
+
home: 3,
|
|
25
|
+
recents: 187,
|
|
26
|
+
app_switch: 187,
|
|
27
|
+
menu: 82,
|
|
28
|
+
enter: 66,
|
|
29
|
+
escape: 111,
|
|
30
|
+
del: 67,
|
|
31
|
+
delete: 67,
|
|
32
|
+
tab: 61,
|
|
33
|
+
space: 62,
|
|
34
|
+
up: 19,
|
|
35
|
+
down: 20,
|
|
36
|
+
left: 21,
|
|
37
|
+
right: 22,
|
|
38
|
+
volume_up: 24,
|
|
39
|
+
volume_down: 25,
|
|
40
|
+
mute: 164,
|
|
41
|
+
power: 26,
|
|
42
|
+
wakeup: 224,
|
|
43
|
+
sleep: 223,
|
|
44
|
+
camera: 27,
|
|
45
|
+
search: 84,
|
|
46
|
+
page_up: 92,
|
|
47
|
+
page_down: 93,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 把按键名或数字转成 keycode。 */
|
|
51
|
+
export function resolveKeyCode(key) {
|
|
52
|
+
const text = String(key ?? '').trim().toLowerCase()
|
|
53
|
+
if (/^\d+$/u.test(text)) return Number(text)
|
|
54
|
+
return KEY_CODES[text]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 杀进程树。 */
|
|
58
|
+
function killTree(child) {
|
|
59
|
+
if (child === undefined || child === null || child.killed) return
|
|
60
|
+
try {
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
63
|
+
killer.on('error', () => {})
|
|
64
|
+
} else {
|
|
65
|
+
child.kill('SIGKILL')
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// 忽略
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 跑一个子进程并收集输出。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} file
|
|
76
|
+
* @param {string[]} args
|
|
77
|
+
* @param {{ timeoutMs?: number, maxBytes?: number, binary?: boolean }} [options]
|
|
78
|
+
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, buffer?:Buffer, truncated?:boolean, elapsedMs:number, error?:string}>}
|
|
79
|
+
*/
|
|
80
|
+
export function runProcess(file, args, options = {}) {
|
|
81
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000
|
|
82
|
+
const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : TEXT_MAX_BYTES
|
|
83
|
+
const binary = options.binary === true
|
|
84
|
+
const started = Date.now()
|
|
85
|
+
|
|
86
|
+
return new Promise((resolvePromise) => {
|
|
87
|
+
let child
|
|
88
|
+
try {
|
|
89
|
+
child = spawn(file, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
90
|
+
} catch (error) {
|
|
91
|
+
resolvePromise({ ok: false, code: null, stdout: '', stderr: '', elapsedMs: 0, error: error instanceof Error ? error.message : String(error) })
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stdoutChunks = []
|
|
96
|
+
const stderrChunks = []
|
|
97
|
+
let stdoutBytes = 0
|
|
98
|
+
let stderrBytes = 0
|
|
99
|
+
let truncated = false
|
|
100
|
+
let settled = false
|
|
101
|
+
let timedOut = false
|
|
102
|
+
|
|
103
|
+
const finish = (payload) => {
|
|
104
|
+
if (settled) return
|
|
105
|
+
settled = true
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
resolvePromise({
|
|
108
|
+
ok: payload.code === 0 && timedOut === false,
|
|
109
|
+
code: payload.code,
|
|
110
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
111
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
112
|
+
...(binary ? { buffer: Buffer.concat(stdoutChunks) } : {}),
|
|
113
|
+
truncated,
|
|
114
|
+
elapsedMs: Date.now() - started,
|
|
115
|
+
...(timedOut ? { error: `命令超时(${timeoutMs}ms)` } : {}),
|
|
116
|
+
...(payload.error ? { error: payload.error } : {}),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timedOut = true
|
|
122
|
+
killTree(child)
|
|
123
|
+
}, timeoutMs)
|
|
124
|
+
|
|
125
|
+
child.stdout.on('data', (chunk) => {
|
|
126
|
+
stdoutBytes += chunk.length
|
|
127
|
+
if (stdoutBytes > maxBytes) {
|
|
128
|
+
truncated = true
|
|
129
|
+
const room = maxBytes - (stdoutBytes - chunk.length)
|
|
130
|
+
if (room > 0) stdoutChunks.push(chunk.subarray(0, room))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
stdoutChunks.push(chunk)
|
|
134
|
+
})
|
|
135
|
+
child.stderr.on('data', (chunk) => {
|
|
136
|
+
stderrBytes += chunk.length
|
|
137
|
+
if (stderrBytes > maxBytes) {
|
|
138
|
+
truncated = true
|
|
139
|
+
const room = maxBytes - (stderrBytes - chunk.length)
|
|
140
|
+
if (room > 0) stderrChunks.push(chunk.subarray(0, room))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
stderrChunks.push(chunk)
|
|
144
|
+
})
|
|
145
|
+
child.on('error', (error) => {
|
|
146
|
+
finish({ code: null, error: error instanceof Error ? error.message : String(error) })
|
|
147
|
+
})
|
|
148
|
+
child.on('close', (code) => {
|
|
149
|
+
finish({ code })
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 解析 `adb devices -l`。
|
|
156
|
+
* @param {string} output
|
|
157
|
+
* @returns {Array<{serial:string,state:string,model:string,product:string,device:string,transportId:string}>}
|
|
158
|
+
*/
|
|
159
|
+
export function parseAdbDevices(output) {
|
|
160
|
+
const rows = []
|
|
161
|
+
for (const raw of String(output ?? '').split(/\r?\n/u)) {
|
|
162
|
+
const line = raw.trim()
|
|
163
|
+
if (line.length === 0) continue
|
|
164
|
+
if (line.startsWith('List of devices') || line.startsWith('*')) continue
|
|
165
|
+
const parts = line.split(/\s+/u)
|
|
166
|
+
if (parts.length < 2) continue
|
|
167
|
+
const serial = parts[0]
|
|
168
|
+
const state = parts[1]
|
|
169
|
+
if (!/^(device|offline|unauthorized|bootloader|recovery|sideload|authorizing|connecting)$/u.test(state)) continue
|
|
170
|
+
const extras = {}
|
|
171
|
+
for (const token of parts.slice(2)) {
|
|
172
|
+
const index = token.indexOf(':')
|
|
173
|
+
if (index > 0) extras[token.slice(0, index)] = token.slice(index + 1)
|
|
174
|
+
}
|
|
175
|
+
rows.push({
|
|
176
|
+
serial,
|
|
177
|
+
state,
|
|
178
|
+
model: extras.model ?? '',
|
|
179
|
+
product: extras.product ?? '',
|
|
180
|
+
device: extras.device ?? '',
|
|
181
|
+
transportId: extras.transport_id ?? '',
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return rows
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 从 PNG 头里读宽高。 */
|
|
188
|
+
export function pngSize(buffer) {
|
|
189
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 24) return undefined
|
|
190
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) return undefined
|
|
191
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 从设备列表里选一台可用设备。 */
|
|
195
|
+
export function pickDevice(devices, preferred) {
|
|
196
|
+
const list = Array.isArray(devices) ? devices : []
|
|
197
|
+
const online = list.filter((item) => item.state === 'device')
|
|
198
|
+
const wanted = String(preferred ?? '').trim()
|
|
199
|
+
if (wanted.length > 0) {
|
|
200
|
+
const hit = online.find((item) => item.serial === wanted)
|
|
201
|
+
if (hit !== undefined) return { ok: true, device: hit }
|
|
202
|
+
}
|
|
203
|
+
if (online.length === 1) return { ok: true, device: online[0] }
|
|
204
|
+
if (online.length === 0) {
|
|
205
|
+
const unauthorized = list.filter((item) => item.state === 'unauthorized')
|
|
206
|
+
const offline = list.filter((item) => item.state === 'offline')
|
|
207
|
+
if (unauthorized.length > 0) {
|
|
208
|
+
return { ok: false, error: `设备未授权:${unauthorized.map((item) => item.serial).join(', ')}。请在手机上点「允许 USB 调试」。` }
|
|
209
|
+
}
|
|
210
|
+
if (offline.length > 0) {
|
|
211
|
+
return { ok: false, error: `设备离线:${offline.map((item) => item.serial).join(', ')}。请检查连接或重新 adb connect。` }
|
|
212
|
+
}
|
|
213
|
+
return { ok: false, error: '没有检测到 Android 设备。请插 USB 或先在设置里连接无线调试。' }
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: `发现 ${online.length} 台设备,但还没有选择当前调试设备。请在「设置 → 安卓调试」或面板顶部选一台。`,
|
|
218
|
+
multiple: online.map((item) => item.serial),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** adb 操作集合。 */
|
|
223
|
+
export class Adb {
|
|
224
|
+
/**
|
|
225
|
+
* @param {object} options
|
|
226
|
+
* @param {() => object} options.getConfig
|
|
227
|
+
* @param {string} options.pluginRoot
|
|
228
|
+
* @param {object} [options.logger]
|
|
229
|
+
*/
|
|
230
|
+
constructor(options) {
|
|
231
|
+
this.getConfig = options.getConfig
|
|
232
|
+
this.pluginRoot = options.pluginRoot
|
|
233
|
+
this.logger = options.logger
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 解析 adb 路径并校验存在。 */
|
|
237
|
+
resolve() {
|
|
238
|
+
const settings = this.getConfig()
|
|
239
|
+
const paths = resolveExecutablePaths({
|
|
240
|
+
pluginRoot: this.pluginRoot,
|
|
241
|
+
adbPath: settings.adbPath,
|
|
242
|
+
scrcpyPath: settings.scrcpyPath,
|
|
243
|
+
join,
|
|
244
|
+
})
|
|
245
|
+
if (!existsSync(paths.adbPath)) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `找不到 adb:${paths.adbPath}。请在「设置 → 安卓调试」里填正确的 adb.exe 路径。`,
|
|
249
|
+
paths,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, path: paths.adbPath, paths }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 解析 scrcpy 路径。 */
|
|
256
|
+
resolveScrcpy() {
|
|
257
|
+
const settings = this.getConfig()
|
|
258
|
+
return resolveExecutablePaths({
|
|
259
|
+
pluginRoot: this.pluginRoot,
|
|
260
|
+
adbPath: settings.adbPath,
|
|
261
|
+
scrcpyPath: settings.scrcpyPath,
|
|
262
|
+
join,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 执行 adb 子命令(自动加 -s)。
|
|
268
|
+
* @param {string[]} args
|
|
269
|
+
* @param {{serial?:string, timeoutMs?:number, maxBytes?:number, binary?:boolean}} [options]
|
|
270
|
+
*/
|
|
271
|
+
async run(args, options = {}) {
|
|
272
|
+
const resolved = this.resolve()
|
|
273
|
+
if (!resolved.ok) return { ok: false, code: null, stdout: '', stderr: '', elapsedMs: 0, error: resolved.error }
|
|
274
|
+
const list = Array.isArray(args) ? [...args] : []
|
|
275
|
+
const serial = String(options.serial ?? '').trim()
|
|
276
|
+
const argv = serial.length > 0 ? ['-s', serial, ...list] : list
|
|
277
|
+
const result = await runProcess(resolved.path, argv, {
|
|
278
|
+
timeoutMs: options.timeoutMs ?? this.getConfig().adbTimeoutMs,
|
|
279
|
+
maxBytes: options.maxBytes,
|
|
280
|
+
binary: options.binary === true,
|
|
281
|
+
})
|
|
282
|
+
if (result.ok !== true && result.error === undefined) {
|
|
283
|
+
result.error = (result.stderr || '').trim() || `adb 退出码 ${result.code ?? '?'}`
|
|
284
|
+
}
|
|
285
|
+
return result
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `adb version`。 */
|
|
289
|
+
async version() {
|
|
290
|
+
const result = await this.run(['version'], { timeoutMs: 8000 })
|
|
291
|
+
if (result.ok !== true) return result
|
|
292
|
+
return { ok: true, version: result.stdout.trim(), path: this.resolve().path }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** `adb devices -l`。 */
|
|
296
|
+
async devices() {
|
|
297
|
+
const result = await this.run(['devices', '-l'], { timeoutMs: 12000 })
|
|
298
|
+
if (result.ok !== true) return { ok: false, error: result.error, devices: [] }
|
|
299
|
+
return { ok: true, devices: parseAdbDevices(result.stdout), raw: result.stdout.trim() }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 选中设备(返回 ok/device/error)。 */
|
|
303
|
+
async selectDevice(preferred) {
|
|
304
|
+
const listed = await this.devices()
|
|
305
|
+
if (listed.ok !== true) return { ok: false, error: listed.error, devices: [] }
|
|
306
|
+
const picked = pickDevice(listed.devices, preferred)
|
|
307
|
+
return { ...picked, devices: listed.devices }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 连接无线设备。 */
|
|
311
|
+
async connect(host, port) {
|
|
312
|
+
const target = `${String(host ?? '').trim()}:${Number(port) || 5555}`
|
|
313
|
+
if (String(host ?? '').trim().length === 0) return { ok: false, error: '请先填写设备 IP。' }
|
|
314
|
+
const result = await this.run(['connect', target], { timeoutMs: 15000 })
|
|
315
|
+
return { ok: result.ok === true, target, output: (result.stdout || result.stderr || '').trim(), error: result.ok === true ? undefined : result.error }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 断开无线设备。 */
|
|
319
|
+
async disconnect(host, port) {
|
|
320
|
+
const target = `${String(host ?? '').trim()}:${Number(port) || 5555}`
|
|
321
|
+
const result = await this.run(['disconnect', target], { timeoutMs: 10000 })
|
|
322
|
+
return { ok: result.ok === true, target, output: (result.stdout || result.stderr || '').trim(), error: result.ok === true ? undefined : result.error }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Android 11+ 无线调试配对。 */
|
|
326
|
+
async pair(host, port, code) {
|
|
327
|
+
const target = `${String(host ?? '').trim()}:${Number(port)}`
|
|
328
|
+
if (String(host ?? '').trim().length === 0) return { ok: false, error: '请先填写设备 IP。' }
|
|
329
|
+
if (!Number(port)) return { ok: false, error: '请填写无线调试的配对端口。' }
|
|
330
|
+
if (String(code ?? '').trim().length === 0) return { ok: false, error: '请填写手机屏幕上显示的配对码。' }
|
|
331
|
+
const result = await this.run(['pair', target, String(code).trim()], { timeoutMs: 30000 })
|
|
332
|
+
return { ok: result.ok === true, target, output: (result.stdout || result.stderr || '').trim(), error: result.ok === true ? undefined : result.error }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** USB 已连接时开启 TCP 模式。 */
|
|
336
|
+
async enableTcpip(serial, port = 5555) {
|
|
337
|
+
const result = await this.run(['tcpip', String(port)], { serial, timeoutMs: 15000 })
|
|
338
|
+
return { ok: result.ok === true, port, output: (result.stdout || result.stderr || '').trim(), error: result.ok === true ? undefined : result.error }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 执行 shell 命令。 */
|
|
342
|
+
async shell(serial, command, options = {}) {
|
|
343
|
+
const text = String(command ?? '').trim()
|
|
344
|
+
if (text.length === 0) return { ok: false, error: '命令为空。' }
|
|
345
|
+
return this.run(['shell', text], { serial, timeoutMs: options.timeoutMs })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 发送按键。 */
|
|
349
|
+
async keyevent(serial, key) {
|
|
350
|
+
const code = resolveKeyCode(key)
|
|
351
|
+
if (code === undefined) return { ok: false, error: `不认识的按键:${key}` }
|
|
352
|
+
return this.run(['shell', `input keyevent ${code}`], { serial, timeoutMs: 8000 })
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** tap。 */
|
|
356
|
+
async tap(serial, x, y) {
|
|
357
|
+
return this.run(['shell', `input tap ${Math.round(Number(x))} ${Math.round(Number(y))}`], { serial, timeoutMs: 8000 })
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 长按 / 滑动。 */
|
|
361
|
+
async swipe(serial, x1, y1, x2, y2, durationMs = 300) {
|
|
362
|
+
const ms = Math.max(50, Math.round(Number(durationMs) || 300))
|
|
363
|
+
return this.run(
|
|
364
|
+
['shell', `input swipe ${Math.round(Number(x1))} ${Math.round(Number(y1))} ${Math.round(Number(x2))} ${Math.round(Number(y2))} ${ms}`],
|
|
365
|
+
{ serial, timeoutMs: 12000 },
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 输入 ASCII 文本(Android input 不支持中文,中文会返回提示)。 */
|
|
370
|
+
async inputText(serial, text) {
|
|
371
|
+
const source = String(text ?? '')
|
|
372
|
+
if (source.length === 0) return { ok: false, error: '文本为空。' }
|
|
373
|
+
if (/[^\x20-\x7e]/u.test(source)) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
error: 'adb input text 只支持 ASCII。中文/emoji 请用「打开 scrcpy 窗口」在电脑键盘输入,或先装输入法辅助应用。',
|
|
377
|
+
unsupported: true,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const escaped = source.replace(/ /gu, '%s').replace(/'/gu, `'\\''`)
|
|
381
|
+
return this.run(['shell', `input text '${escaped}'`], { serial, timeoutMs: 8000 })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 截图,返回 PNG Buffer。
|
|
386
|
+
* 优先 exec-out(二进制安全);失败时退回 shell screencap 到设备临时文件再 exec-out cat。
|
|
387
|
+
*/
|
|
388
|
+
async screencap(serial) {
|
|
389
|
+
const direct = await this.run(['exec-out', 'screencap', '-p'], {
|
|
390
|
+
serial,
|
|
391
|
+
timeoutMs: 15000,
|
|
392
|
+
maxBytes: IMAGE_MAX_BYTES,
|
|
393
|
+
binary: true,
|
|
394
|
+
})
|
|
395
|
+
if (direct.ok === true && pngSize(direct.buffer) !== undefined) {
|
|
396
|
+
const size = pngSize(direct.buffer)
|
|
397
|
+
return { ok: true, buffer: direct.buffer, width: size.width, height: size.height }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const remote = `/data/local/tmp/dsh-android-${Date.now()}.png`
|
|
401
|
+
const shot = await this.run(['shell', `screencap -p ${remote}`], { serial, timeoutMs: 15000 })
|
|
402
|
+
if (shot.ok !== true) {
|
|
403
|
+
return { ok: false, error: shot.error ?? '截图失败', stderr: direct.stderr }
|
|
404
|
+
}
|
|
405
|
+
const pulled = await this.run(['exec-out', 'cat', remote], {
|
|
406
|
+
serial,
|
|
407
|
+
timeoutMs: 15000,
|
|
408
|
+
maxBytes: IMAGE_MAX_BYTES,
|
|
409
|
+
binary: true,
|
|
410
|
+
})
|
|
411
|
+
void this.run(['shell', `rm -f ${remote}`], { serial, timeoutMs: 3000 })
|
|
412
|
+
const size = pngSize(pulled.buffer)
|
|
413
|
+
if (pulled.ok === true && size !== undefined) {
|
|
414
|
+
return { ok: true, buffer: pulled.buffer, width: size.width, height: size.height }
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: `截图失败:${pulled.error ?? direct.error ?? '返回数据不是 PNG'}`,
|
|
419
|
+
stderr: `${direct.stderr || ''}\n${pulled.stderr || ''}`.trim(),
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-android-tool 的 Config schema。
|
|
3
|
+
*
|
|
4
|
+
* 每个字段都过 `volatile()`:DSH 设置页只允许写 volatile 字段,volatile 更新走
|
|
5
|
+
* `loader/volatile-update`、就地提交、不重挂插件,所以 apply() 只跑一次;
|
|
6
|
+
* 业务代码每次用时都要读实时值(见 config.js: snapshotConfig)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { DEFAULTS } from './config.js'
|
|
11
|
+
|
|
12
|
+
/** 把 schema 标记为 volatile(老版本 schemastery 没有就退化)。 */
|
|
13
|
+
function live(schema) {
|
|
14
|
+
return typeof schema?.volatile === 'function' ? schema.volatile() : schema
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Config = z.object({
|
|
18
|
+
adbPath: live(
|
|
19
|
+
z
|
|
20
|
+
.string()
|
|
21
|
+
.default(DEFAULTS.adbPath)
|
|
22
|
+
.description('adb 可执行文件路径。留空 = 使用本插件 scrcpy-win64-v5.0.1 目录里的 adb.exe。'),
|
|
23
|
+
),
|
|
24
|
+
scrcpyPath: live(
|
|
25
|
+
z
|
|
26
|
+
.string()
|
|
27
|
+
.default(DEFAULTS.scrcpyPath)
|
|
28
|
+
.description('scrcpy 可执行文件路径。精简包默认不含 scrcpy.exe;如果需要「打开 scrcpy 窗口」,请填本机 scrcpy.exe 的路径。'),
|
|
29
|
+
),
|
|
30
|
+
wirelessHost: live(z.string().default(DEFAULTS.wirelessHost).description('无线调试的设备 IP,例如 192.168.1.23。留空 = 未配置。')),
|
|
31
|
+
wirelessPort: live(z.number().step(1).min(1).max(65535).default(DEFAULTS.wirelessPort).description('adb connect 使用的端口,Android 无线调试通常是 5555 或系统给出的端口。')),
|
|
32
|
+
pairingPort: live(z.number().step(1).min(0).max(65535).default(DEFAULTS.pairingPort).description('Android 11+ 无线调试配对端口。0 = 未设置;配对码只用于一次配对,不保存。')),
|
|
33
|
+
autoConnect: live(z.boolean().default(DEFAULTS.autoConnect).description('插件加载后自动 adb connect 配置的无线设备。')),
|
|
34
|
+
videoMaxSize: live(z.number().step(1).min(320).max(4096).default(DEFAULTS.videoMaxSize).description('scrcpy 画面最大边长(像素)。越小越省带宽。')),
|
|
35
|
+
videoBitRate: live(z.number().step(100000).min(100000).max(50000000).default(DEFAULTS.videoBitRate).description('scrcpy 视频码率(bit/s)。默认 4 Mbps。')),
|
|
36
|
+
videoFps: live(z.number().step(1).min(1).max(120).default(DEFAULTS.videoFps).description('scrcpy 最大帧率。')),
|
|
37
|
+
previewFps: live(z.number().step(1).min(1).max(10).default(DEFAULTS.previewFps).description('WebCodecs 不可用时的截图轮询帧率(1–10)。')),
|
|
38
|
+
logcatMaxLines: live(z.number().step(100).min(100).max(100000).default(DEFAULTS.logcatMaxLines).description('服务端 logcat 环形缓冲最大行数。')),
|
|
39
|
+
adbTimeoutMs: live(z.number().step(500).min(1000).max(120000).default(DEFAULTS.adbTimeoutMs).description('单次 adb 命令默认超时(毫秒)。')),
|
|
40
|
+
scrcpyExtraArgs: live(z.string().default(DEFAULTS.scrcpyExtraArgs).description('追加给独立 scrcpy 窗口的启动参数(空格分隔,高级用户使用)。')),
|
|
41
|
+
})
|