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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ }