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 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
- # Temporary Holding Version
1
+ # dsh-phone-control
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,10 @@
1
+ - insert:
2
+ - id: phone-control
3
+ name: dsh-phone-control
4
+ config:
5
+ # 0 = 启动时自动挑一个空闲端口(8000–9999),挑中的端口会写入
6
+ # <profile>/phone-bridge.json 并在之后的启动里沿用。
7
+ # 想固定端口就改成具体数字,例如 8788。
8
+ port: 0
9
+ bindHost: "0.0.0.0"
10
+ autoCode: true
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
+ }