dsh-android-tool 0.0.0-stage → 0.1.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/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/tools.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面向模型的 Android 工具。
|
|
3
|
+
*
|
|
4
|
+
* 注册在 host 层(所有会话可见)。工具名用 snake_case,与 DSH 自带工具一致。
|
|
5
|
+
* 所有工具只做参数校验并转给 AndroidService;失败返回结构化 { ok:false, error }。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const OUTPUT = {
|
|
9
|
+
schema: { type: 'object', additionalProperties: true },
|
|
10
|
+
render: (_args, value) => {
|
|
11
|
+
const blocks = [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
12
|
+
const image = value?.image
|
|
13
|
+
if (image !== undefined && image !== null && typeof image.attachmentId === 'string') {
|
|
14
|
+
blocks.push({ type: 'image', attachment: image })
|
|
15
|
+
}
|
|
16
|
+
return blocks
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {import('./service.js').AndroidService} deps.service
|
|
23
|
+
*/
|
|
24
|
+
export function buildTools(deps) {
|
|
25
|
+
const service = deps.service
|
|
26
|
+
|
|
27
|
+
const view = {
|
|
28
|
+
name: 'android_view',
|
|
29
|
+
description: [
|
|
30
|
+
'Capture the current screen of the selected Android device and return it as an image so you can see it.',
|
|
31
|
+
'The screenshot is also pushed to the right-side "安卓设备" panel. Use it before/after any control action.',
|
|
32
|
+
'Returns serial, model, current package/activity, width/height, capture time, and the image itself.',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
parameters: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
serial: { type: 'string', description: 'Optional device serial. Defaults to the selected device in settings.' },
|
|
39
|
+
savePath: { type: 'string', description: 'Optional absolute path to also save the PNG to.' },
|
|
40
|
+
includeState: { type: 'boolean', description: 'Include current model/package/activity in the result. Default true.' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
output: OUTPUT,
|
|
44
|
+
isConcurrencySafe: () => false,
|
|
45
|
+
timeoutMs: 40000,
|
|
46
|
+
async execute(args) {
|
|
47
|
+
return service.screencapture({
|
|
48
|
+
serial: args?.serial,
|
|
49
|
+
savePath: args?.savePath,
|
|
50
|
+
includeState: args?.includeState !== false,
|
|
51
|
+
mark: true,
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
presentCall() {
|
|
55
|
+
return { card: 'generic', title: 'Android 截图', kind: 'read' }
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const state = {
|
|
60
|
+
name: 'android_state',
|
|
61
|
+
description: [
|
|
62
|
+
'Query the selected Android device status: model, brand, Android version, battery (level/temperature/charging),',
|
|
63
|
+
'WiFi (enabled/SSID/IP/airplane mode), memory, storage, screen size/density/wakefulness, and the current foreground app.',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
parameters: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
serial: { type: 'string', description: 'Optional device serial. Defaults to the selected device.' },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
output: OUTPUT,
|
|
73
|
+
isConcurrencySafe: () => false,
|
|
74
|
+
timeoutMs: 40000,
|
|
75
|
+
async execute(args) {
|
|
76
|
+
return service.deviceState({ serial: args?.serial })
|
|
77
|
+
},
|
|
78
|
+
presentCall() {
|
|
79
|
+
return { card: 'generic', title: 'Android 设备状态', kind: 'read' }
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const control = {
|
|
84
|
+
name: 'android_control',
|
|
85
|
+
description: [
|
|
86
|
+
'Simulate touch/key input on the selected Android device. When the live screen stream is running, it uses the',
|
|
87
|
+
'low-latency scrcpy control channel (touch/key/text/scroll); otherwise it falls back to `adb shell input`. Actions:',
|
|
88
|
+
'- tap: {x,y}; long_press: {x,y,durationMs}; swipe: {x1,y1,x2,y2,durationMs}',
|
|
89
|
+
'- key: {key} (back/home/recents/enter/volume_up/volume_down/power/menu/wakeup or a numeric keycode)',
|
|
90
|
+
'- text: {text} (scrcpy channel supports Unicode; adb fallback is ASCII only)',
|
|
91
|
+
'- scroll: {direction: up|down|left|right, amount}; wake; open_app: {package}',
|
|
92
|
+
'Coordinates are device-screen pixels; when using scrcpy they are scaled to video coordinates automatically.',
|
|
93
|
+
'By default it captures a screenshot after the action and returns it as an image (capture:false to skip).',
|
|
94
|
+
].join('\n'),
|
|
95
|
+
parameters: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
properties: {
|
|
99
|
+
action: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
required: true,
|
|
102
|
+
enum: ['tap', 'long_press', 'swipe', 'key', 'text', 'scroll', 'wake', 'open_app'],
|
|
103
|
+
description: 'The input action.',
|
|
104
|
+
},
|
|
105
|
+
x: { type: 'number', description: 'Tap/long-press/swipe start X (device pixels).' },
|
|
106
|
+
y: { type: 'number', description: 'Tap/long-press/swipe start Y (device pixels).' },
|
|
107
|
+
x2: { type: 'number', description: 'Swipe end X.' },
|
|
108
|
+
y2: { type: 'number', description: 'Swipe end Y.' },
|
|
109
|
+
durationMs: { type: 'integer', description: 'Long-press / swipe duration in milliseconds.' },
|
|
110
|
+
key: { type: 'string', description: 'Key name or numeric Android keycode.' },
|
|
111
|
+
text: { type: 'string', description: 'ASCII text to type.' },
|
|
112
|
+
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Scroll direction.' },
|
|
113
|
+
amount: { type: 'integer', description: 'Scroll distance in pixels.' },
|
|
114
|
+
package: { type: 'string', description: 'Package name for open_app.' },
|
|
115
|
+
serial: { type: 'string', description: 'Optional device serial.' },
|
|
116
|
+
capture: { type: 'boolean', description: 'Capture a screenshot after the action. Default true.' },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
output: OUTPUT,
|
|
120
|
+
isConcurrencySafe: () => false,
|
|
121
|
+
timeoutMs: 90000,
|
|
122
|
+
async execute(args) {
|
|
123
|
+
return service.control({
|
|
124
|
+
action: args?.action,
|
|
125
|
+
x: args?.x,
|
|
126
|
+
y: args?.y,
|
|
127
|
+
x2: args?.x2,
|
|
128
|
+
y2: args?.y2,
|
|
129
|
+
durationMs: args?.durationMs,
|
|
130
|
+
key: args?.key,
|
|
131
|
+
text: args?.text,
|
|
132
|
+
direction: args?.direction,
|
|
133
|
+
amount: args?.amount,
|
|
134
|
+
package: args?.package,
|
|
135
|
+
serial: args?.serial,
|
|
136
|
+
capture: args?.capture !== false,
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
presentCall(args) {
|
|
140
|
+
return { card: 'generic', title: `Android ${String(args?.action ?? 'control')}`, kind: 'write' }
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const adb = {
|
|
145
|
+
name: 'android_adb',
|
|
146
|
+
description: [
|
|
147
|
+
'Last-resort: run a raw adb command against the selected device using the plugin-configured adb.',
|
|
148
|
+
'Prefer android_view / android_state / android_control for normal work; use this only when they cannot express it.',
|
|
149
|
+
'mode=shell (default) runs `adb -s <serial> shell <command>`; mode=adb treats `command` as adb arguments.',
|
|
150
|
+
'Returns exit code, stdout, stderr, elapsed time. Interactive commands are not supported and will time out.',
|
|
151
|
+
].join('\n'),
|
|
152
|
+
parameters: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
command: { type: 'string', required: true, description: 'Shell command (mode=shell) or adb argv (mode=adb).' },
|
|
157
|
+
mode: { type: 'string', enum: ['shell', 'adb'], description: 'How to interpret command. Default shell.' },
|
|
158
|
+
serial: { type: 'string', description: 'Optional device serial.' },
|
|
159
|
+
timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 20000, max 120000).' },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
output: OUTPUT,
|
|
163
|
+
isConcurrencySafe: () => false,
|
|
164
|
+
timeoutMs: 130000,
|
|
165
|
+
async execute(args) {
|
|
166
|
+
return service.adbCommand({
|
|
167
|
+
command: args?.command,
|
|
168
|
+
mode: args?.mode,
|
|
169
|
+
serial: args?.serial,
|
|
170
|
+
timeoutMs: args?.timeoutMs,
|
|
171
|
+
})
|
|
172
|
+
},
|
|
173
|
+
presentCall(args) {
|
|
174
|
+
return { card: 'generic', title: `adb ${String(args?.command ?? '').slice(0, 40)}`, kind: 'write' }
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return [view, state, control, adb]
|
|
179
|
+
}
|
package/lib/webapi.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板与设置页的 HTTP / WebSocket 接口(host 半边)。
|
|
3
|
+
*
|
|
4
|
+
* 前缀统一 `/x-android-tool`;WS 用精确 upgrade 路径。
|
|
5
|
+
* 读接口在没设备时也返回 200 + 结构化原因,不让前端白屏。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 各路由完整路径。 */
|
|
9
|
+
export const WEB_ROUTES = {
|
|
10
|
+
state: '/x-android-tool/state.json',
|
|
11
|
+
screenshot: '/x-android-tool/screenshot.png',
|
|
12
|
+
command: '/x-android-tool/command',
|
|
13
|
+
logcat: '/x-android-tool/logcat.json',
|
|
14
|
+
logcatExportText: '/x-android-tool/logcat/export.txt',
|
|
15
|
+
logcatExport: '/x-android-tool/logcat/export',
|
|
16
|
+
packages: '/x-android-tool/packages.json',
|
|
17
|
+
perf: '/x-android-tool/perf.json',
|
|
18
|
+
exec: '/x-android-tool/exec',
|
|
19
|
+
videoWs: '/x-android-tool/video.ws',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_BODY_BYTES = 512 * 1024
|
|
23
|
+
|
|
24
|
+
/** 读 JSON 请求体。 */
|
|
25
|
+
async function readJsonBody(req) {
|
|
26
|
+
const chunks = []
|
|
27
|
+
let size = 0
|
|
28
|
+
for await (const chunk of req) {
|
|
29
|
+
size += chunk.length
|
|
30
|
+
if (size > MAX_BODY_BYTES) throw new Error('请求体过大')
|
|
31
|
+
chunks.push(chunk)
|
|
32
|
+
}
|
|
33
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
34
|
+
if (text.length === 0) return {}
|
|
35
|
+
const parsed = JSON.parse(text)
|
|
36
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 统一 JSON 响应。 */
|
|
40
|
+
function sendJson(res, status, payload) {
|
|
41
|
+
res.statusCode = status
|
|
42
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
43
|
+
res.setHeader('cache-control', 'no-store')
|
|
44
|
+
res.end(JSON.stringify(payload))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从 URLSearchParams 取过滤参数。 */
|
|
48
|
+
function filtersFrom(search) {
|
|
49
|
+
return {
|
|
50
|
+
serial: search.get('serial') ?? undefined,
|
|
51
|
+
cursor: Number(search.get('cursor') ?? 0) || 0,
|
|
52
|
+
pkg: search.get('pkg') ?? '',
|
|
53
|
+
q: search.get('q') ?? '',
|
|
54
|
+
level: search.get('level') ?? 'V',
|
|
55
|
+
limit: Number(search.get('limit') ?? 0) || undefined,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 注册 HTTP 前缀路由与视频 WebSocket。
|
|
61
|
+
* @param {object} webServer
|
|
62
|
+
* @param {{service: import('./service.js').AndroidService, logger?: object}} source
|
|
63
|
+
* @returns {() => void}
|
|
64
|
+
*/
|
|
65
|
+
export function registerWebRoutes(webServer, source) {
|
|
66
|
+
const service = source.service
|
|
67
|
+
|
|
68
|
+
const stopHttp = webServer.register({
|
|
69
|
+
kind: 'prefix',
|
|
70
|
+
path: '/x-android-tool',
|
|
71
|
+
async handler(req, res) {
|
|
72
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
73
|
+
const pathname = url.pathname
|
|
74
|
+
const method = req.method ?? 'GET'
|
|
75
|
+
const search = url.searchParams
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
if ((method === 'GET' || method === 'HEAD') && pathname === WEB_ROUTES.state) {
|
|
79
|
+
const state = await service.uiState()
|
|
80
|
+
return sendJson(res, 200, state)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if ((method === 'GET' || method === 'HEAD') && pathname === WEB_ROUTES.screenshot) {
|
|
84
|
+
const shot = await service.screenshotBuffer(search.get('serial') ?? undefined)
|
|
85
|
+
if (shot.ok !== true || !Buffer.isBuffer(shot.buffer)) {
|
|
86
|
+
res.statusCode = 404
|
|
87
|
+
res.setHeader('cache-control', 'no-store')
|
|
88
|
+
res.end(shot.error ?? 'no screenshot')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
res.statusCode = 200
|
|
92
|
+
res.setHeader('content-type', 'image/png')
|
|
93
|
+
res.setHeader('content-length', String(shot.buffer.length))
|
|
94
|
+
res.setHeader('cache-control', 'no-store')
|
|
95
|
+
res.end(method === 'HEAD' ? undefined : shot.buffer)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (method === 'GET' && pathname === WEB_ROUTES.logcat) {
|
|
100
|
+
const result = await service.logcatRead(filtersFrom(search))
|
|
101
|
+
return sendJson(res, 200, result)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (method === 'GET' && pathname === WEB_ROUTES.logcatExportText) {
|
|
105
|
+
const filters = filtersFrom(search)
|
|
106
|
+
const result = await service.exportLogcatText(filters)
|
|
107
|
+
if (result.ok !== true) return sendJson(res, 200, { ok: false, error: result.error })
|
|
108
|
+
const name = `logcat-${new Date().toISOString().replace(/[:.]/gu, '-')}.txt`
|
|
109
|
+
res.statusCode = 200
|
|
110
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
111
|
+
res.setHeader('content-disposition', `attachment; filename="${name}"`)
|
|
112
|
+
res.setHeader('cache-control', 'no-store')
|
|
113
|
+
res.end(result.text)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (method === 'GET' && pathname === WEB_ROUTES.packages) {
|
|
118
|
+
const result = await service.packages({ serial: search.get('serial') ?? undefined })
|
|
119
|
+
return sendJson(res, 200, result)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (method === 'GET' && pathname === WEB_ROUTES.perf) {
|
|
123
|
+
const result = await service.perfSample({
|
|
124
|
+
serial: search.get('serial') ?? undefined,
|
|
125
|
+
pkg: search.get('pkg') ?? '',
|
|
126
|
+
})
|
|
127
|
+
return sendJson(res, 200, result)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
131
|
+
return sendJson(res, 404, { ok: false, error: '未知路由' })
|
|
132
|
+
}
|
|
133
|
+
if (method !== 'POST') {
|
|
134
|
+
res.statusCode = 405
|
|
135
|
+
res.setHeader('allow', 'GET, HEAD, POST')
|
|
136
|
+
res.end()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let body
|
|
141
|
+
try {
|
|
142
|
+
body = await readJsonBody(req)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (pathname === WEB_ROUTES.command) {
|
|
148
|
+
const result = await service.humanCommand(body)
|
|
149
|
+
return sendJson(res, 200, result)
|
|
150
|
+
}
|
|
151
|
+
if (pathname === WEB_ROUTES.logcatExport) {
|
|
152
|
+
const result = await service.exportLogcat(body)
|
|
153
|
+
return sendJson(res, 200, result)
|
|
154
|
+
}
|
|
155
|
+
if (pathname === WEB_ROUTES.exec) {
|
|
156
|
+
const result = await service.adbCommand(body)
|
|
157
|
+
return sendJson(res, 200, result)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return sendJson(res, 404, { ok: false, error: '未知路由' })
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return sendJson(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const stopUpgrade = webServer.registerUpgrade({
|
|
168
|
+
path: WEB_ROUTES.videoWs,
|
|
169
|
+
handler(req, socket, head) {
|
|
170
|
+
try {
|
|
171
|
+
service.handleVideoUpgrade(req, socket, head)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
socket.destroy()
|
|
175
|
+
} catch {
|
|
176
|
+
// 忽略
|
|
177
|
+
}
|
|
178
|
+
source.logger?.warn?.(`dsh-android-tool: video ws 失败 ${error instanceof Error ? error.message : String(error)}`)
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return () => {
|
|
184
|
+
stopUpgrade?.()
|
|
185
|
+
stopHttp?.()
|
|
186
|
+
}
|
|
187
|
+
}
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最小 WebSocket 服务端工具(RFC 6455)。
|
|
3
|
+
*
|
|
4
|
+
* 只做本项目需要的事:握手、发送文本/二进制、回复 ping/close、解析客户端
|
|
5
|
+
* 的 close/ping/pong。不处理分片消息和扩展协商——浏览器发来的控制帧都很小。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
11
|
+
|
|
12
|
+
/** 计算 Sec-WebSocket-Accept。 */
|
|
13
|
+
export function acceptKey(key) {
|
|
14
|
+
return createHash('sha1').update(`${String(key ?? '')}${WS_GUID}`).digest('base64')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 生成 101 响应文本;缺少 key 时返回 undefined。
|
|
19
|
+
* @param {{ headers: Record<string, string|string[]|undefined> }} req
|
|
20
|
+
*/
|
|
21
|
+
export function handshakeResponse(req) {
|
|
22
|
+
const raw = req?.headers?.['sec-websocket-key']
|
|
23
|
+
const key = Array.isArray(raw) ? raw[0] : raw
|
|
24
|
+
if (typeof key !== 'string' || key.length === 0) return undefined
|
|
25
|
+
return [
|
|
26
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
27
|
+
'Upgrade: websocket',
|
|
28
|
+
'Connection: Upgrade',
|
|
29
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
30
|
+
'',
|
|
31
|
+
'',
|
|
32
|
+
].join('\r\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 编码一个服务端帧(不掩码)。 */
|
|
36
|
+
export function encodeFrame(opcode, payload) {
|
|
37
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload ?? ''), 'utf8')
|
|
38
|
+
const length = body.length
|
|
39
|
+
let header
|
|
40
|
+
if (length < 126) {
|
|
41
|
+
header = Buffer.from([0x80 | opcode, length])
|
|
42
|
+
} else if (length < 65536) {
|
|
43
|
+
header = Buffer.alloc(4)
|
|
44
|
+
header[0] = 0x80 | opcode
|
|
45
|
+
header[1] = 126
|
|
46
|
+
header.writeUInt16BE(length, 2)
|
|
47
|
+
} else {
|
|
48
|
+
header = Buffer.alloc(10)
|
|
49
|
+
header[0] = 0x80 | opcode
|
|
50
|
+
header[1] = 127
|
|
51
|
+
header.writeBigUInt64BE(BigInt(length), 2)
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat([header, body])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const encodeText = (text) => encodeFrame(0x1, text)
|
|
57
|
+
export const encodeBinary = (buffer) => encodeFrame(0x2, buffer)
|
|
58
|
+
export const encodePing = () => encodeFrame(0x9, Buffer.alloc(0))
|
|
59
|
+
export const encodePong = (payload) => encodeFrame(0xa, payload ?? Buffer.alloc(0))
|
|
60
|
+
export const encodeClose = () => encodeFrame(0x8, Buffer.alloc(0))
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 解析客户端发来的帧(客户端帧一定带掩码)。
|
|
64
|
+
* @param {Buffer} buffer
|
|
65
|
+
* @returns {{frames:Array<{opcode:number,payload:Buffer,fin:boolean}>, rest:Buffer}}
|
|
66
|
+
*/
|
|
67
|
+
export function parseFrames(buffer) {
|
|
68
|
+
const frames = []
|
|
69
|
+
let offset = 0
|
|
70
|
+
while (offset + 2 <= buffer.length) {
|
|
71
|
+
const first = buffer[offset]
|
|
72
|
+
const second = buffer[offset + 1]
|
|
73
|
+
const fin = (first & 0x80) !== 0
|
|
74
|
+
const opcode = first & 0x0f
|
|
75
|
+
const masked = (second & 0x80) !== 0
|
|
76
|
+
let length = second & 0x7f
|
|
77
|
+
let cursor = offset + 2
|
|
78
|
+
if (length === 126) {
|
|
79
|
+
if (cursor + 2 > buffer.length) break
|
|
80
|
+
length = buffer.readUInt16BE(cursor)
|
|
81
|
+
cursor += 2
|
|
82
|
+
} else if (length === 127) {
|
|
83
|
+
if (cursor + 8 > buffer.length) break
|
|
84
|
+
const big = buffer.readBigUInt64BE(cursor)
|
|
85
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) break
|
|
86
|
+
length = Number(big)
|
|
87
|
+
cursor += 8
|
|
88
|
+
}
|
|
89
|
+
let mask
|
|
90
|
+
if (masked) {
|
|
91
|
+
if (cursor + 4 > buffer.length) break
|
|
92
|
+
mask = buffer.subarray(cursor, cursor + 4)
|
|
93
|
+
cursor += 4
|
|
94
|
+
}
|
|
95
|
+
if (cursor + length > buffer.length) break
|
|
96
|
+
let payload = buffer.subarray(cursor, cursor + length)
|
|
97
|
+
if (mask !== undefined) {
|
|
98
|
+
const copy = Buffer.allocUnsafe(payload.length)
|
|
99
|
+
for (let index = 0; index < payload.length; index += 1) copy[index] = payload[index] ^ mask[index % 4]
|
|
100
|
+
payload = copy
|
|
101
|
+
}
|
|
102
|
+
frames.push({ opcode, payload: Buffer.from(payload), fin })
|
|
103
|
+
offset = cursor + length
|
|
104
|
+
}
|
|
105
|
+
return { frames, rest: buffer.subarray(offset) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 一个已升级的连接。 */
|
|
109
|
+
export class WsConnection {
|
|
110
|
+
/**
|
|
111
|
+
* @param {import('node:stream').Duplex} socket
|
|
112
|
+
* @param {object} req
|
|
113
|
+
*/
|
|
114
|
+
constructor(socket, req) {
|
|
115
|
+
this.socket = socket
|
|
116
|
+
this.req = req
|
|
117
|
+
this.closed = false
|
|
118
|
+
this.buffer = Buffer.alloc(0)
|
|
119
|
+
this.onClose = undefined
|
|
120
|
+
this.onMessage = undefined
|
|
121
|
+
|
|
122
|
+
socket.on('data', (chunk) => this.handleData(chunk))
|
|
123
|
+
socket.on('error', () => this.destroy())
|
|
124
|
+
socket.on('close', () => {
|
|
125
|
+
this.closed = true
|
|
126
|
+
this.onClose?.()
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
handleData(chunk) {
|
|
131
|
+
this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)])
|
|
132
|
+
const { frames, rest } = parseFrames(this.buffer)
|
|
133
|
+
this.buffer = rest
|
|
134
|
+
for (const frame of frames) {
|
|
135
|
+
if (frame.opcode === 0x8) {
|
|
136
|
+
this.send(encodeClose())
|
|
137
|
+
this.destroy()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (frame.opcode === 0x9) {
|
|
141
|
+
this.send(encodePong(frame.payload))
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (frame.opcode === 0xa) continue
|
|
145
|
+
this.onMessage?.(frame)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
send(buffer) {
|
|
150
|
+
if (this.closed) return false
|
|
151
|
+
try {
|
|
152
|
+
this.socket.write(buffer)
|
|
153
|
+
return true
|
|
154
|
+
} catch {
|
|
155
|
+
this.destroy()
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
sendText(text) {
|
|
161
|
+
return this.send(encodeText(text))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sendBinary(buffer) {
|
|
165
|
+
return this.send(encodeBinary(buffer))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ping() {
|
|
169
|
+
return this.send(encodePing())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
close() {
|
|
173
|
+
this.send(encodeClose())
|
|
174
|
+
this.destroy()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
destroy() {
|
|
178
|
+
if (this.closed) return
|
|
179
|
+
this.closed = true
|
|
180
|
+
try {
|
|
181
|
+
this.socket.destroy()
|
|
182
|
+
} catch {
|
|
183
|
+
// 忽略
|
|
184
|
+
}
|
|
185
|
+
this.onClose?.()
|
|
186
|
+
}
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-android-tool",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "把 Android 设备接进 DSH:设置页配置无线调试与 adb,右侧四 Tab 看画面 / Logcat / 性能 / 指令,并给 AI android_view / android_state / android_control / android_adb 四个工具",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./client": "./client.js",
|
|
11
|
+
"./icon": "./icon.svg",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"client.js",
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"icon.svg",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"scrcpy-win64-v5.0.1"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dsh",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"cordis",
|
|
28
|
+
"plugin",
|
|
29
|
+
"android",
|
|
30
|
+
"adb",
|
|
31
|
+
"scrcpy",
|
|
32
|
+
"logcat"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/TouMingQAQ/dsh-android-tool.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/TouMingQAQ/dsh-android-tool#readme",
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20",
|
|
53
|
+
"dsh": ">=0.2.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --test \"test/*.test.js\""
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@deepseek-ai/schemastery": ">=3.18.4"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"registry": "https://registry.npmjs.org/"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@yume-chan/scrcpy": "^3.0.0-beta.4"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
Binary file
|
|
Binary file
|