dsh-phone-control 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 +60 -2
- package/cordis.patch.yml +10 -0
- package/index.js +379 -0
- package/lib/client.js +347 -0
- package/lib/protocol.js +133 -0
- package/lib/routes.js +151 -0
- package/lib/service.js +826 -0
- package/package.json +54 -4
- package/skills/phone-control/SKILL.md +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-phone-control contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-phone-control
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A DeepSeek Harness plugin that drives a real Android phone: read the screen as an accessibility **node tree**,
|
|
4
|
+
tap by **text**, type into fields, screenshot only when it helps.
|
|
5
|
+
|
|
6
|
+
Docs: this file (English) · [简体中文](../README.zh-CN.md)
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
A 1080×2400 screenshot is 1–2 MB of PNG. The node tree for the same screen is 1–3 KB of JSON with labels,
|
|
11
|
+
view ids and clickable flags — usually a few hundred tokens instead of tens of thousands.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install dsh-phone-control --prefix ~/.dsh/profiles/desktop
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add `"dsh-phone-control"` to `dsh.profile.bundles` in that profile's `package.json`, then restart DSH.
|
|
20
|
+
The settings panel appears under *Settings → Phone bridge*.
|
|
21
|
+
|
|
22
|
+
## Pair in five steps
|
|
23
|
+
|
|
24
|
+
1. Install the Android app from the repository's releases page.
|
|
25
|
+
2. Enable its **Accessibility service**.
|
|
26
|
+
3. Open *Settings → Phone bridge* in DSH and read the 8-digit code.
|
|
27
|
+
4. Type the code into the app and tap **Connect**.
|
|
28
|
+
5. The panel turns green; the phone keeps a 30-day token and reconnects by itself afterwards.
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
| Tool | What it does |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `phone_ui_dump` | Accessibility tree as JSON (`maxDepth`, `maxNodes`, `includeInvisible`) |
|
|
35
|
+
| `phone_tap_text` | Click the widget matching text / content-description / view-id |
|
|
36
|
+
| `phone_set_text` | Write into a field located by hint or view-id |
|
|
37
|
+
| `phone_screenshot` | PNG of the current screen |
|
|
38
|
+
| `phone_tap` / `phone_long_press` / `phone_swipe` | Coordinate input |
|
|
39
|
+
| `phone_input` / `phone_key` | Type text; send `back` / `home` / `recents` / `notifications` |
|
|
40
|
+
| `phone_open_app` / `phone_current_app` / `phone_list_apps` | Launch, identify, enumerate apps |
|
|
41
|
+
| `phone_status` / `phone_pair_code` / `phone_disconnect` | Listener state, pairing code, drop session |
|
|
42
|
+
|
|
43
|
+
## Settings panel
|
|
44
|
+
|
|
45
|
+
Pairing code with countdown and a *New code* button, listening address/port (`0` = auto), bind address,
|
|
46
|
+
paired devices, open sessions, failed attempts, and a *Disconnect* button. Port changes rebind immediately and
|
|
47
|
+
persist to `<profile>/phone-bridge.json`.
|
|
48
|
+
|
|
49
|
+
## Protocol
|
|
50
|
+
|
|
51
|
+
Binary frames (`DSHP`, 19-byte header, 8 MB cap). Handshake: `HELLO` → `CHALLENGE` → `AUTH` → `OK`, session key
|
|
52
|
+
from ephemeral ECDH (prime256v1) + PBKDF2-stretched pairing code through HKDF, HMAC proofs both ways, then
|
|
53
|
+
AES-256-GCM with the sequence number in the nonce. Only the SHA-256 of the pairing token is stored.
|
|
54
|
+
|
|
55
|
+
## Node version
|
|
56
|
+
|
|
57
|
+
Requires Node.js 20 or newer. Runtime dependency: `ws`.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { SpikeService } from './lib/service.js'
|
|
4
|
+
import { BASE_PATH, createHandler } from './lib/routes.js'
|
|
5
|
+
|
|
6
|
+
export const name = 'dsh-phone-control'
|
|
7
|
+
export const inject = ['tools', 'skills']
|
|
8
|
+
|
|
9
|
+
const OUTPUT = {
|
|
10
|
+
schema: { type: 'object', additionalProperties: true },
|
|
11
|
+
render: (_args, value) => {
|
|
12
|
+
const blocks = [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
13
|
+
const image = value?.image
|
|
14
|
+
if (image !== undefined && image !== null && typeof image.attachmentId === 'string') {
|
|
15
|
+
blocks.push({ type: 'image', attachment: image })
|
|
16
|
+
}
|
|
17
|
+
return blocks
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SKILL_DIR = new URL('./skills/phone-control/', import.meta.url)
|
|
22
|
+
const SKILL_PATH = new URL('./skills/phone-control/SKILL.md', import.meta.url)
|
|
23
|
+
|
|
24
|
+
const CANDIDATE = {
|
|
25
|
+
name: 'phone-control',
|
|
26
|
+
description: '通过 DSH 手机桥操控已配对的 Android 手机:截图、坐标点击、长按、滑动、输入文本、返回/桌面/最近任务、打开应用。',
|
|
27
|
+
invocation: {
|
|
28
|
+
modelInvocable: true,
|
|
29
|
+
userInvocable: true,
|
|
30
|
+
},
|
|
31
|
+
provider: 'dsh-phone-control',
|
|
32
|
+
source: 'bundled',
|
|
33
|
+
resourceBase: { kind: 'directory', path: fileURLToPath(SKILL_DIR) },
|
|
34
|
+
rank: 50,
|
|
35
|
+
locator: fileURLToPath(SKILL_PATH),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const provider = {
|
|
39
|
+
name: 'dsh-phone-control',
|
|
40
|
+
async list() {
|
|
41
|
+
return [CANDIDATE]
|
|
42
|
+
},
|
|
43
|
+
async get(candidate) {
|
|
44
|
+
if (candidate?.name !== 'phone-control') return undefined
|
|
45
|
+
const { rank: _rank, locator: entryPath, ...summary } = candidate
|
|
46
|
+
const raw = await readFile(entryPath, 'utf8')
|
|
47
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u, '').trim()
|
|
48
|
+
return {
|
|
49
|
+
...summary,
|
|
50
|
+
content: body,
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NUMBER = { type: 'number' }
|
|
56
|
+
|
|
57
|
+
function makeTool({ name, description, properties = {}, required = [], timeoutMs = 20000, present, run }) {
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
description,
|
|
61
|
+
parameters: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties,
|
|
64
|
+
required,
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
output: OUTPUT,
|
|
68
|
+
isConcurrencySafe: () => false,
|
|
69
|
+
timeoutMs,
|
|
70
|
+
async execute(args) {
|
|
71
|
+
return run(args ?? {})
|
|
72
|
+
},
|
|
73
|
+
presentCall() {
|
|
74
|
+
return present
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function imageResult(ctx, raw) {
|
|
80
|
+
const encoded = String(raw?.imageB64 ?? '')
|
|
81
|
+
if (encoded.length === 0) throw new Error('PHONE_IMAGE: 手机没有返回截图数据')
|
|
82
|
+
const data = Buffer.from(encoded, 'base64')
|
|
83
|
+
if (data.length === 0) throw new Error('PHONE_IMAGE: 截图数据为空')
|
|
84
|
+
let attachments
|
|
85
|
+
try {
|
|
86
|
+
attachments = ctx.get('attachments')
|
|
87
|
+
} catch {
|
|
88
|
+
attachments = undefined
|
|
89
|
+
}
|
|
90
|
+
if (attachments === undefined || typeof attachments.saveImage !== 'function') {
|
|
91
|
+
throw new Error('ATTACHMENTS_UNAVAILABLE: DSH 附件服务不可用')
|
|
92
|
+
}
|
|
93
|
+
const ref = await attachments.saveImage({
|
|
94
|
+
data,
|
|
95
|
+
mediaType: 'image/png',
|
|
96
|
+
name: `phone-${Date.now()}.png`,
|
|
97
|
+
})
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
width: raw.width ?? ref.width,
|
|
101
|
+
height: raw.height ?? ref.height,
|
|
102
|
+
bytes: data.length,
|
|
103
|
+
capturedAt: raw.capturedAt ?? Date.now(),
|
|
104
|
+
image: {
|
|
105
|
+
attachmentId: ref.attachmentId,
|
|
106
|
+
mediaType: ref.mediaType,
|
|
107
|
+
bytes: ref.bytes,
|
|
108
|
+
width: raw.width ?? ref.width,
|
|
109
|
+
height: raw.height ?? ref.height,
|
|
110
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function apply(ctx, config) {
|
|
116
|
+
const service = new SpikeService({ ctx, config })
|
|
117
|
+
ctx.provide('phoneBridgeSpike', service)
|
|
118
|
+
|
|
119
|
+
// ---- settings page transport -------------------------------------------
|
|
120
|
+
// Acquired through ctx.inject rather than a module-level `inject` entry: a
|
|
121
|
+
// profile without a web server (a TUI profile) must still load this plugin,
|
|
122
|
+
// and ctx.inject parks only this child fiber instead of the whole plugin.
|
|
123
|
+
try {
|
|
124
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
125
|
+
webCtx.effect(
|
|
126
|
+
() => webCtx.webServer.register({
|
|
127
|
+
kind: 'prefix',
|
|
128
|
+
path: BASE_PATH,
|
|
129
|
+
handler: createHandler(service, { logger: ctx.logger }),
|
|
130
|
+
}),
|
|
131
|
+
'dsh-phone-control: settings API',
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
} catch (error) {
|
|
135
|
+
ctx.logger?.warn?.(
|
|
136
|
+
`[phone-control] 没有可用的 web 服务,设置页面已禁用: ${error?.message ?? String(error)}`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const definitions = [
|
|
141
|
+
makeTool({
|
|
142
|
+
name: 'phone_status',
|
|
143
|
+
description: '查看手机桥状态:是否监听、手机是否已连接、配对码、端口和待处理请求数。',
|
|
144
|
+
timeoutMs: 10000,
|
|
145
|
+
present: { card: 'generic', title: '手机桥状态', kind: 'read' },
|
|
146
|
+
async run() {
|
|
147
|
+
return service.status()
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
makeTool({
|
|
151
|
+
name: 'phone_pair_code',
|
|
152
|
+
description: '查看当前 8 位配对码、监听端口和绑定地址;在手机 App 的“配对码”中输入这 8 位数字。传 refresh=true 可强制换一个新码(并解除错误次数锁定)。',
|
|
153
|
+
properties: {
|
|
154
|
+
refresh: { type: 'boolean', description: '强制生成新的 8 位配对码并解除锁定,默认 false' },
|
|
155
|
+
},
|
|
156
|
+
timeoutMs: 10000,
|
|
157
|
+
present: { card: 'generic', title: '配对码', kind: 'read' },
|
|
158
|
+
async run(args) {
|
|
159
|
+
if (args?.refresh === true) service.refreshCode(true)
|
|
160
|
+
let s = service.status()
|
|
161
|
+
if (args?.refresh !== true && Date.now() > s.codeExpiresAt && s.lockedUntil === null) {
|
|
162
|
+
service.refreshCode()
|
|
163
|
+
s = service.status()
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
code: s.pairingCode,
|
|
167
|
+
port: s.port,
|
|
168
|
+
bindHost: s.bindHost,
|
|
169
|
+
paired: s.paired,
|
|
170
|
+
connected: s.connected,
|
|
171
|
+
expiresAt: s.codeExpiresAt,
|
|
172
|
+
ttlSec: Math.max(0, Math.round((s.codeExpiresAt - Date.now()) / 1000)),
|
|
173
|
+
failedAttempts: s.failedAttempts,
|
|
174
|
+
lockedUntil: s.lockedUntil,
|
|
175
|
+
lockedRemainSec: s.lockedUntil === null ? 0 : Math.max(0, Math.round((s.lockedUntil - Date.now()) / 1000)),
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
makeTool({
|
|
180
|
+
name: 'phone_screenshot',
|
|
181
|
+
description: '截取手机当前屏幕并返回 PNG 图片。坐标类工具应使用这张图片的像素坐标。',
|
|
182
|
+
timeoutMs: 30000,
|
|
183
|
+
present: { card: 'generic', title: '手机截图', kind: 'read' },
|
|
184
|
+
async run() {
|
|
185
|
+
const raw = await service.request('screenshot', {}, 25000)
|
|
186
|
+
return imageResult(ctx, raw)
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
makeTool({
|
|
190
|
+
name: 'phone_ui_dump',
|
|
191
|
+
description:
|
|
192
|
+
'读取手机当前屏幕的控件树(无障碍节点):text / contentDescription / view-id / bounds / 是否可点击可编辑。比截图小 1–2 个数量级、且不受分辨率与动画影响;想「看屏幕上有什么」优先用它,只有节点树为空(游戏、Canvas、部分 Flutter 界面)时才回退 phone_screenshot。',
|
|
193
|
+
properties: {
|
|
194
|
+
maxDepth: { type: 'number', description: '最大深度,默认 30' },
|
|
195
|
+
maxNodes: { type: 'number', description: '最多返回多少个节点,默认 400' },
|
|
196
|
+
includeInvisible: { type: 'boolean', description: '是否包含不可见节点,默认 false' },
|
|
197
|
+
},
|
|
198
|
+
timeoutMs: 20000,
|
|
199
|
+
present: { card: 'generic', title: '手机控件树', kind: 'read' },
|
|
200
|
+
async run(args) {
|
|
201
|
+
return service.request(
|
|
202
|
+
'ui_dump',
|
|
203
|
+
{
|
|
204
|
+
maxDepth: Number(args?.maxDepth ?? 30),
|
|
205
|
+
maxNodes: Number(args?.maxNodes ?? 400),
|
|
206
|
+
includeInvisible: args?.includeInvisible === true,
|
|
207
|
+
},
|
|
208
|
+
15000,
|
|
209
|
+
)
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
makeTool({
|
|
213
|
+
name: 'phone_tap_text',
|
|
214
|
+
description:
|
|
215
|
+
'按文字点击手机屏幕上的控件(优先无障碍 ACTION_CLICK,自绘控件退回按 bounds 中心手势点击)。比「先截图再换算坐标」更稳更省,是点击按钮/列表项的首选。',
|
|
216
|
+
properties: {
|
|
217
|
+
text: { type: 'string', description: '要点击的文字 / contentDescription / view-id 片段' },
|
|
218
|
+
exact: { type: 'boolean', description: '是否要求完全相等,默认 false(包含匹配)' },
|
|
219
|
+
index: { type: 'number', description: '命中多个时取第几个(从 0 开始),默认 0,可点击的优先' },
|
|
220
|
+
},
|
|
221
|
+
required: ['text'],
|
|
222
|
+
timeoutMs: 15000,
|
|
223
|
+
present: { card: 'generic', title: '按文字点击', kind: 'action' },
|
|
224
|
+
async run(args) {
|
|
225
|
+
return service.request(
|
|
226
|
+
'tap_text',
|
|
227
|
+
{ text: String(args.text ?? ''), exact: args?.exact === true, index: Number(args?.index ?? 0) },
|
|
228
|
+
10000,
|
|
229
|
+
)
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
makeTool({
|
|
233
|
+
name: 'phone_set_text',
|
|
234
|
+
description:
|
|
235
|
+
'把文字直接写入输入框(无障碍 ACTION_SET_TEXT,不经键盘):不传 target 就写进当前聚焦的输入框,传 target 则先按文字定位那个输入框。失败会返回 NOT_EDITABLE,此时改用 phone_input。',
|
|
236
|
+
properties: {
|
|
237
|
+
text: { type: 'string', description: '要写入的文字(会替换原内容)' },
|
|
238
|
+
target: { type: 'string', description: '可选:输入框的提示文字 / view-id 片段,用于定位' },
|
|
239
|
+
},
|
|
240
|
+
required: ['text'],
|
|
241
|
+
timeoutMs: 20000,
|
|
242
|
+
present: { card: 'generic', title: '写入输入框', kind: 'action' },
|
|
243
|
+
async run(args) {
|
|
244
|
+
return service.request(
|
|
245
|
+
'set_text',
|
|
246
|
+
{ text: String(args.text ?? ''), ...(args?.target === undefined ? {} : { target: String(args.target) }) },
|
|
247
|
+
15000,
|
|
248
|
+
)
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
makeTool({
|
|
252
|
+
name: 'phone_tap',
|
|
253
|
+
description: '点击手机屏幕上的一个坐标。坐标系与 phone_screenshot 返回图片的像素坐标一致。',
|
|
254
|
+
properties: { x: NUMBER, y: NUMBER },
|
|
255
|
+
required: ['x', 'y'],
|
|
256
|
+
timeoutMs: 15000,
|
|
257
|
+
present: { card: 'generic', title: '手机点击', kind: 'action' },
|
|
258
|
+
async run(args) {
|
|
259
|
+
return service.request('tap', { x: Number(args.x), y: Number(args.y), durationMs: 50 }, 10000)
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
makeTool({
|
|
263
|
+
name: 'phone_long_press',
|
|
264
|
+
description: '在手机屏幕坐标上长按,默认 800ms。',
|
|
265
|
+
properties: { x: NUMBER, y: NUMBER, durationMs: { type: 'number', minimum: 100, maximum: 10000 } },
|
|
266
|
+
required: ['x', 'y'],
|
|
267
|
+
timeoutMs: 15000,
|
|
268
|
+
present: { card: 'generic', title: '手机长按', kind: 'action' },
|
|
269
|
+
async run(args) {
|
|
270
|
+
return service.request(
|
|
271
|
+
'long_press',
|
|
272
|
+
{ x: Number(args.x), y: Number(args.y), durationMs: Number(args.durationMs ?? 800) },
|
|
273
|
+
10000,
|
|
274
|
+
)
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
makeTool({
|
|
278
|
+
name: 'phone_swipe',
|
|
279
|
+
description: '从 (x1,y1) 滑动到 (x2,y2),默认 300ms。',
|
|
280
|
+
properties: {
|
|
281
|
+
x1: NUMBER,
|
|
282
|
+
y1: NUMBER,
|
|
283
|
+
x2: NUMBER,
|
|
284
|
+
y2: NUMBER,
|
|
285
|
+
durationMs: { type: 'number', minimum: 50, maximum: 10000 },
|
|
286
|
+
},
|
|
287
|
+
required: ['x1', 'y1', 'x2', 'y2'],
|
|
288
|
+
timeoutMs: 15000,
|
|
289
|
+
present: { card: 'generic', title: '手机滑动', kind: 'action' },
|
|
290
|
+
async run(args) {
|
|
291
|
+
return service.request(
|
|
292
|
+
'swipe',
|
|
293
|
+
{
|
|
294
|
+
x1: Number(args.x1),
|
|
295
|
+
y1: Number(args.y1),
|
|
296
|
+
x2: Number(args.x2),
|
|
297
|
+
y2: Number(args.y2),
|
|
298
|
+
durationMs: Number(args.durationMs ?? 300),
|
|
299
|
+
},
|
|
300
|
+
10000,
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
makeTool({
|
|
305
|
+
name: 'phone_input',
|
|
306
|
+
description: '向手机当前获得焦点的输入框写入文本(替换原内容)。失败时返回 INPUT_UNSUPPORTED。',
|
|
307
|
+
properties: { text: { type: 'string' } },
|
|
308
|
+
required: ['text'],
|
|
309
|
+
timeoutMs: 20000,
|
|
310
|
+
present: { card: 'generic', title: '手机输入', kind: 'action' },
|
|
311
|
+
async run(args) {
|
|
312
|
+
return service.request('input', { text: String(args.text ?? '') }, 15000)
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
makeTool({
|
|
316
|
+
name: 'phone_key',
|
|
317
|
+
description: '发送全局按键:back(返回)、home(桌面)、recents(最近任务)、notifications(通知栏)。',
|
|
318
|
+
properties: { key: { type: 'string', enum: ['back', 'home', 'recents', 'notifications'] } },
|
|
319
|
+
required: ['key'],
|
|
320
|
+
timeoutMs: 10000,
|
|
321
|
+
present: { card: 'generic', title: '手机按键', kind: 'action' },
|
|
322
|
+
async run(args) {
|
|
323
|
+
return service.request('key', { key: String(args.key) }, 8000)
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
makeTool({
|
|
327
|
+
name: 'phone_open_app',
|
|
328
|
+
description: '通过包名打开手机上的应用。',
|
|
329
|
+
properties: { package: { type: 'string' } },
|
|
330
|
+
required: ['package'],
|
|
331
|
+
timeoutMs: 15000,
|
|
332
|
+
present: { card: 'generic', title: '打开手机应用', kind: 'action' },
|
|
333
|
+
async run(args) {
|
|
334
|
+
return service.request('open_app', { package: String(args.package) }, 10000)
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
makeTool({
|
|
338
|
+
name: 'phone_current_app',
|
|
339
|
+
description: '返回手机当前前台应用的包名和界面标题。',
|
|
340
|
+
timeoutMs: 10000,
|
|
341
|
+
present: { card: 'generic', title: '手机前台应用', kind: 'read' },
|
|
342
|
+
async run() {
|
|
343
|
+
return service.request('current_app', {}, 8000)
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
makeTool({
|
|
347
|
+
name: 'phone_list_apps',
|
|
348
|
+
description: '列出手机已安装且可启动的应用(包名与名称)。',
|
|
349
|
+
timeoutMs: 15000,
|
|
350
|
+
present: { card: 'generic', title: '手机应用列表', kind: 'read' },
|
|
351
|
+
async run() {
|
|
352
|
+
return service.request('list_apps', {}, 12000)
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
makeTool({
|
|
356
|
+
name: 'phone_disconnect',
|
|
357
|
+
description: '从电脑端断开当前手机连接;手机通知会变为未连接。',
|
|
358
|
+
timeoutMs: 10000,
|
|
359
|
+
present: { card: 'generic', title: '断开手机桥', kind: 'action' },
|
|
360
|
+
async run() {
|
|
361
|
+
const closed = service.disconnectAll()
|
|
362
|
+
return { ok: true, closed, status: service.status() }
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
for (const definition of definitions) {
|
|
368
|
+
ctx.effect(() => ctx.tools.register(definition), `dsh-phone-control: ${definition.name}`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
ctx.skills.registerProvider(() => provider)
|
|
372
|
+
|
|
373
|
+
ctx.effect(() => {
|
|
374
|
+
void service.start().catch((error) => {
|
|
375
|
+
ctx.logger?.warn?.(`[phone-control] start failed: ${error?.message ?? String(error)}`)
|
|
376
|
+
})
|
|
377
|
+
return () => service.dispose()
|
|
378
|
+
}, 'dsh-phone-control: tcp listener')
|
|
379
|
+
}
|