dsh-phone-control 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -23,7 +23,8 @@ The settings panel appears under *Settings → Phone bridge*.
23
23
 
24
24
  1. Install the Android app from the repository's releases page.
25
25
  2. Enable its **Accessibility service**.
26
- 3. Open *Settings → Phone bridge* in DSH and read the 8-digit code.
26
+ 3. Open *Settings → Phone bridge* in DSH and read the 8-digit code — or scan the QR code the same panel renders
27
+ (*Scan pairing* in the app fills in code, port and host).
27
28
  4. Type the code into the app and tap **Connect**.
28
29
  5. The panel turns green; the phone keeps a 30-day token and reconnects by itself afterwards.
29
30
 
@@ -38,13 +39,20 @@ The settings panel appears under *Settings → Phone bridge*.
38
39
  | `phone_tap` / `phone_long_press` / `phone_swipe` | Coordinate input |
39
40
  | `phone_input` / `phone_key` | Type text; send `back` / `home` / `recents` / `notifications` |
40
41
  | `phone_open_app` / `phone_current_app` / `phone_list_apps` | Launch, identify, enumerate apps |
42
+ | `phone_fs_list` / `phone_fs_stat` | List a directory (default `/sdcard`) / stat a path |
43
+ | `phone_fs_read` / `phone_fs_write` | Read a file (UTF-8 or base64, paged) / write text or base64 |
44
+ | `phone_fs_mkdir` / `phone_fs_rm` | Create a directory tree / delete a file or directory |
41
45
  | `phone_status` / `phone_pair_code` / `phone_disconnect` | Listener state, pairing code, drop session |
42
46
 
47
+ While the phone's foreground app is the bridge itself, the input tools answer `SELF_PROTECTED`; the app's
48
+ *Permissions & security* card has a switch (off by default) that lifts it. The file tools stay inside the app's
49
+ private directory until all-files access is granted (`PERMISSION_DENIED` otherwise).
50
+
43
51
  ## Settings panel
44
52
 
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`.
53
+ Pairing code with countdown and a *New code* button, a live QR code of the pairing payload (`GET /dsh-phone-control/qr`
54
+ on the DSH web port), listening address/port (`0` = auto), bind address, paired devices, open sessions, failed
55
+ attempts, and a *Disconnect* button. Port changes rebind immediately and persist to `<profile>/phone-bridge.json`.
48
56
 
49
57
  ## Protocol
50
58
 
@@ -54,7 +62,7 @@ AES-256-GCM with the sequence number in the nonce. Only the SHA-256 of the pairi
54
62
 
55
63
  ## Node version
56
64
 
57
- Requires Node.js 20 or newer. Runtime dependency: `ws`.
65
+ Requires Node.js 20 or newer. Runtime dependencies: `ws`, `qrcode` (the SVG QR route).
58
66
 
59
67
  ## License
60
68
 
package/index.js CHANGED
@@ -248,6 +248,138 @@ export function apply(ctx, config) {
248
248
  )
249
249
  },
250
250
  }),
251
+ makeTool({
252
+ name: 'phone_fs_list',
253
+ description:
254
+ '列出手机上的一个目录(默认 /sdcard):名称、完整路径、是否目录、大小、修改时间、是否可读可写。需要手机端已打开「权限与安全 → 文件读写」,否则只能访问 App 私有目录。',
255
+ properties: {
256
+ path: { type: 'string', description: '目录路径,默认 /sdcard' },
257
+ maxEntries: { type: 'number', description: '最多返回多少条,默认 500,上限 5000' },
258
+ },
259
+ timeoutMs: 30000,
260
+ present: { card: 'generic', title: '手机目录', kind: 'read' },
261
+ async run(args) {
262
+ return service.request(
263
+ 'fs_list',
264
+ { path: String(args?.path ?? '/sdcard'), maxEntries: Number(args?.maxEntries ?? 500) },
265
+ 25000,
266
+ )
267
+ },
268
+ }),
269
+ makeTool({
270
+ name: 'phone_fs_stat',
271
+ description: '查看手机上一个文件或目录的信息:类型、大小、修改时间、可读可写。',
272
+ properties: { path: { type: 'string', description: '文件或目录的绝对路径' } },
273
+ required: ['path'],
274
+ timeoutMs: 15000,
275
+ present: { card: 'generic', title: '手机文件信息', kind: 'read' },
276
+ async run(args) {
277
+ return service.request('fs_stat', { path: String(args.path) }, 10000)
278
+ },
279
+ }),
280
+ makeTool({
281
+ name: 'phone_fs_read',
282
+ description:
283
+ '读取手机上的文件。默认按 UTF-8 文本返回(超过 maxChars 会截断),传 base64: true 则返回原始 base64。大文件用 offset/length 分块,默认一次 512 KB、上限 4 MB。',
284
+ properties: {
285
+ path: { type: 'string', description: '文件绝对路径' },
286
+ offset: { type: 'number', description: '从第几字节开始,默认 0' },
287
+ length: { type: 'number', description: '读多少字节,默认 524288,上限 4194304' },
288
+ maxChars: { type: 'number', description: '文本模式最多返回多少字符,默认 20000' },
289
+ base64: { type: 'boolean', description: '为 true 时返回 base64 而不是文本' },
290
+ },
291
+ required: ['path'],
292
+ timeoutMs: 60000,
293
+ present: { card: 'generic', title: '读取手机文件', kind: 'read' },
294
+ async run(args) {
295
+ const raw = await service.request(
296
+ 'fs_read',
297
+ {
298
+ path: String(args.path),
299
+ offset: Number(args?.offset ?? 0),
300
+ length: Number(args?.length ?? 512 * 1024),
301
+ },
302
+ 55000,
303
+ )
304
+ const buffer = Buffer.from(String(raw?.dataB64 ?? ''), 'base64')
305
+ const base = {
306
+ path: raw?.path,
307
+ offset: raw?.offset,
308
+ bytes: raw?.bytes,
309
+ size: raw?.size,
310
+ eof: raw?.eof,
311
+ nextOffset: raw?.nextOffset,
312
+ }
313
+ if (args?.base64 === true) return { ...base, data: buffer.toString('base64') }
314
+ const text = buffer.toString('utf8')
315
+ const limit = Math.max(0, Number(args?.maxChars ?? 20000))
316
+ return {
317
+ ...base,
318
+ text: text.length > limit ? text.slice(0, limit) : text,
319
+ truncated: text.length > limit,
320
+ }
321
+ },
322
+ }),
323
+ makeTool({
324
+ name: 'phone_fs_write',
325
+ description:
326
+ '往手机写文件:文本用 text,二进制用 dataB64;offset 默认 0,truncate 默认 true(覆盖原文件),传 false 可续写。父目录不存在会自动建。',
327
+ properties: {
328
+ path: { type: 'string', description: '文件绝对路径' },
329
+ text: { type: 'string', description: '要写入的文本(UTF-8)' },
330
+ dataB64: { type: 'string', description: '要写入的二进制(base64),与 text 二选一' },
331
+ offset: { type: 'number', description: '写入起始偏移,默认 0' },
332
+ truncate: { type: 'boolean', description: '是否先截断,默认 true' },
333
+ },
334
+ required: ['path'],
335
+ timeoutMs: 60000,
336
+ present: { card: 'generic', title: '写入手机文件', kind: 'action' },
337
+ async run(args) {
338
+ const hasText = typeof args?.text === 'string'
339
+ const hasData = typeof args?.dataB64 === 'string'
340
+ if (!hasText && !hasData) throw new Error('要写什么?text 和 dataB64 至少给一个')
341
+ const dataB64 = hasData ? String(args.dataB64) : Buffer.from(String(args.text), 'utf8').toString('base64')
342
+ return service.request(
343
+ 'fs_write',
344
+ {
345
+ path: String(args.path),
346
+ dataB64,
347
+ offset: Number(args?.offset ?? 0),
348
+ truncate: args?.truncate !== false,
349
+ },
350
+ 55000,
351
+ )
352
+ },
353
+ }),
354
+ makeTool({
355
+ name: 'phone_fs_mkdir',
356
+ description: '在手机上创建目录(父目录一起建)。',
357
+ properties: { path: { type: 'string', description: '目录绝对路径' } },
358
+ required: ['path'],
359
+ timeoutMs: 15000,
360
+ present: { card: 'generic', title: '建目录', kind: 'action' },
361
+ async run(args) {
362
+ return service.request('fs_mkdir', { path: String(args.path) }, 10000)
363
+ },
364
+ }),
365
+ makeTool({
366
+ name: 'phone_fs_rm',
367
+ description: '删除手机上的文件或目录(目录必须 recursive: true)。',
368
+ properties: {
369
+ path: { type: 'string', description: '要删除的路径' },
370
+ recursive: { type: 'boolean', description: '删目录时传 true' },
371
+ },
372
+ required: ['path'],
373
+ timeoutMs: 30000,
374
+ present: { card: 'generic', title: '删除手机文件', kind: 'action' },
375
+ async run(args) {
376
+ return service.request(
377
+ 'fs_rm',
378
+ { path: String(args.path), recursive: args?.recursive === true },
379
+ 25000,
380
+ )
381
+ },
382
+ }),
251
383
  makeTool({
252
384
  name: 'phone_tap',
253
385
  description: '点击手机屏幕上的一个坐标。坐标系与 phone_screenshot 返回图片的像素坐标一致。',
package/lib/client.js CHANGED
@@ -72,6 +72,7 @@ window.__ModuleLoader__.load({
72
72
  .dshpb-banner { border-radius: 8px; padding: 8px 12px; font-size: 13px; }
73
73
  .dshpb-ok { background: rgba(47,168,95,.12); color: var(--dsw-alias-text-primary, inherit); }
74
74
  .dshpb-err { background: rgba(220,60,60,.14); color: var(--dsw-alias-text-primary, inherit); }
75
+ .dshpb-qr { width: 200px; height: 200px; border-radius: 10px; background: #fff; padding: 8px; }
75
76
  `;
76
77
 
77
78
  function ensureStyle() {
@@ -115,17 +116,51 @@ window.__ModuleLoader__.load({
115
116
  const [busy, setBusy] = React.useState(null);
116
117
  const [port, setPort] = React.useState("");
117
118
  const [bindHost, setBindHost] = React.useState("0.0.0.0");
119
+ const [qrAddr, setQrAddr] = React.useState("");
118
120
  const [now, setNow] = React.useState(Date.now());
119
121
 
122
+ React.useEffect(() => {
123
+ try {
124
+ setQrAddr(window.localStorage.getItem("dshpb.qr.address") ?? "");
125
+ } catch (error) {
126
+ setQrAddr("");
127
+ }
128
+ }, []);
129
+
130
+ const saveQrAddr = (value) => {
131
+ setQrAddr(value);
132
+ try {
133
+ window.localStorage.setItem("dshpb.qr.address", value);
134
+ } catch (error) {
135
+ /* 隐私模式下写不了 localStorage,不影响这次显示 */
136
+ }
137
+ };
138
+
120
139
  React.useEffect(() => {
121
140
  ensureStyle();
122
141
  }, []);
123
142
 
124
143
  React.useEffect(() => {
125
144
  let alive = true;
145
+ let rotating = false;
126
146
  const tick = async () => {
127
147
  try {
128
- const next = await call("/state");
148
+ let next = await call("/state");
149
+ if (!alive) return;
150
+ // 配对码 5 分钟一换:宁可面板上永远显示一个当下的码,
151
+ // 也不要让用户照着过期码去配、白吃一次 BAD_CODE / CODE_EXPIRED。
152
+ const expiresAt = Number(next?.status?.codeExpiresAt ?? 0);
153
+ if (expiresAt > 0 && expiresAt <= Date.now() && !rotating) {
154
+ rotating = true;
155
+ try {
156
+ const fresh = await call("/refresh", {});
157
+ if (fresh !== undefined) next = fresh;
158
+ } catch (cause) {
159
+ /* 换码失败就先把旧状态画出来,下一轮再试 */
160
+ } finally {
161
+ rotating = false;
162
+ }
163
+ }
129
164
  if (!alive) return;
130
165
  setState(next);
131
166
  setError(null);
@@ -159,6 +194,21 @@ window.__ModuleLoader__.load({
159
194
  : "#9AA0A6";
160
195
  const remainMs = status === null ? null : status.codeExpiresAt - now;
161
196
 
197
+ // 二维码里放的就是「手机连接电脑需要的最小信息」:配对码 + 端口(可选地址)。
198
+ // 手机端扫完自动填好,再点一次连接即可;配对码换新时二维码必须跟着变,
199
+ // 所以 src 里带上 code 当缓存钥匙。
200
+ const qrCode = status?.pairingCode ?? "";
201
+ const qrPayload = (() => {
202
+ if (qrCode === "") return "";
203
+ const parts = [`v=1`, `c=${qrCode}`, `p=${status?.port ?? 0}`];
204
+ const addr = qrAddr.trim();
205
+ if (addr !== "") {
206
+ if (/^wss?:\/\//iu.test(addr)) parts.push(`w=${encodeURIComponent(addr)}`);
207
+ else parts.push(`h=${encodeURIComponent(addr)}`);
208
+ }
209
+ return `dsh-phone://pair?${parts.join("&")}`;
210
+ })();
211
+
162
212
  const run = async (key, action, okText) => {
163
213
  setBusy(key);
164
214
  setNotice(null);
@@ -251,6 +301,44 @@ window.__ModuleLoader__.load({
251
301
  ),
252
302
  ),
253
303
  ),
304
+ // ---- 扫码配对
305
+ h(
306
+ "div",
307
+ { className: "dshpb-card" },
308
+ h("div", { className: "dshpb-card-title" }, "扫码配对"),
309
+ qrPayload === ""
310
+ ? h("div", { className: "dshpb-dim" }, "配对码还没生成,稍等一秒。")
311
+ : h(
312
+ "div",
313
+ { className: "dshpb-row" },
314
+ h("img", {
315
+ className: "dshpb-qr",
316
+ alt: "配对二维码",
317
+ width: 200,
318
+ height: 200,
319
+ src: `${BASE}/qr?s=200&d=${encodeURIComponent(qrPayload)}`,
320
+ }),
321
+ h(
322
+ "div",
323
+ { className: "dshpb-dim", style: { maxWidth: "320px" } },
324
+ "手机 App 里点「扫码配对」,对着这块图扫一下:自动填好配对码",
325
+ qrAddr.trim() === "" ? "和端口" : "、端口和地址",
326
+ ",再点「立即连接」即可。配对码每 5 分钟换新,图会跟着自动变。",
327
+ ),
328
+ ),
329
+ h(
330
+ "div",
331
+ { className: "dshpb-row" },
332
+ h("span", { className: "dshpb-label" }, "对外地址"),
333
+ h("input", {
334
+ className: "dshpb-input",
335
+ value: qrAddr,
336
+ placeholder: "可选,如 wss://你的域名/ws",
337
+ onChange: (event) => saveQrAddr(event.target.value),
338
+ }),
339
+ h("span", { className: "dshpb-dim" }, "填了会一并写进二维码,手机扫码就不用再手填"),
340
+ ),
341
+ ),
254
342
  // ---- 监听设置
255
343
  h(
256
344
  "div",
package/lib/routes.js CHANGED
@@ -11,12 +11,17 @@
11
11
  * behave the same way on the same web server.
12
12
  */
13
13
 
14
+ import qrcode from 'qrcode'
15
+
14
16
  /** Base path every route lives under. Keep in sync with lib/client.js. */
15
17
  export const BASE_PATH = '/dsh-phone-control'
16
18
 
17
19
  /** Largest request body accepted, in bytes. */
18
20
  const MAX_BODY_BYTES = 64 * 1024
19
21
 
22
+ /** Longest text we are willing to turn into a QR code. */
23
+ const MAX_QR_TEXT = 512
24
+
20
25
  /**
21
26
  * Whether a request may reach these routes.
22
27
  *
@@ -123,6 +128,24 @@ export function createHandler(service, options = {}) {
123
128
  return
124
129
  }
125
130
 
131
+ if (method === 'GET' && action === 'qr') {
132
+ // Render an arbitrary pairing payload as an SVG QR code. Doing it here
133
+ // keeps the browser bundle script-only (no bundler, no extra deps on
134
+ // the client side) while the phone just scans the picture.
135
+ const text = String(url.searchParams.get('d') ?? '').slice(0, MAX_QR_TEXT)
136
+ const size = Math.min(Math.max(Number(url.searchParams.get('s') ?? 260) || 260, 120), 640)
137
+ const svg = text === ''
138
+ ? ''
139
+ : await qrcode.toString(text, { type: 'svg', margin: 1, width: size, errorCorrectionLevel: 'M' })
140
+ res.writeHead(svg === '' ? 400 : 200, {
141
+ 'content-type': 'image/svg+xml; charset=utf-8',
142
+ 'cache-control': 'no-store',
143
+ 'content-length': Buffer.byteLength(svg),
144
+ })
145
+ res.end(svg)
146
+ return
147
+ }
148
+
126
149
  if (method === 'POST' && action === 'refresh') {
127
150
  service.refreshCode(true)
128
151
  sendJson(res, 200, snapshot())
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-phone-control",
3
- "version": "0.1.2",
4
- "description": "Control an Android phone from DeepSeek Harness: read the accessibility tree, tap by text, type text, take screenshots. Pair the companion app once.",
3
+ "version": "0.1.3",
4
+ "description": "Control an Android phone from DeepSeek Harness: read the accessibility tree, tap by text, type text, take screenshots, read and write files. Pair the companion app once — by code or by scanning a QR code.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -31,6 +31,7 @@
31
31
  "node": ">=20"
32
32
  },
33
33
  "dependencies": {
34
+ "qrcode": "^1.5.4",
34
35
  "ws": "^8.18.0"
35
36
  },
36
37
  "dsh": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: phone-control
3
- description: 通过 DSH 手机桥操控已配对的 Android 手机:先截图确认画面,再按截图像素坐标点击、长按、滑动、输入文本、返回/桌面/最近任务或打开应用。
3
+ description: 通过 DSH 手机桥操控已配对的 Android 手机:读控件树/按文字点击/输入文字,必要时截图和坐标操作,还能列目录、读写文件、建删目录。手机 App 需已连接电脑并开启无障碍服务。
4
4
  user-invocable: true
5
5
  disable-model-invocation: false
6
6
  ---
@@ -17,6 +17,7 @@ disable-model-invocation: false
17
17
  4. **操作后验证。** 点击、输入、滑动后重新 `phone_ui_dump`(或截图)确认界面确实变化;没有变化就报告,不要连续盲点。
18
18
  5. **敏感操作先问。** 支付、转账、发消息、删除数据、修改账号设置前,先向用户确认;用户没有明确要求就不要做。
19
19
  6. **失败就说失败。** `NO_DEVICE` 表示手机没连接;`TIMEOUT` 表示手机没响应;`NOT_EDITABLE` 表示该输入框拒绝直接写入(改用 `phone_input` 或让用户手输);`NODE_NOT_FOUND` 表示树上没有这个文字(先 `phone_ui_dump` 看看实际文案)。
20
+ 7. **文件操作先看清再动手。** 写之前先 `phone_fs_stat` / `phone_fs_read` 确认目标(`truncate` 默认覆盖!);`phone_fs_rm` 删的是用户手机上的真实文件,除非用户明确点名,否则不要删,也不要对 `/sdcard` 之类的大目录跑递归删除。
20
21
 
21
22
  ## 工具
22
23
 
@@ -34,6 +35,10 @@ disable-model-invocation: false
34
35
  - `phone_open_app`:按包名打开应用。
35
36
  - `phone_current_app`:查看当前前台应用。
36
37
  - `phone_list_apps`:列出已安装且可启动的应用(约 250 个)。
38
+ - `phone_fs_list` / `phone_fs_stat`:列目录(默认 `/sdcard`,`maxEntries` 默认 500)、看某个路径的类型/大小/读写权限。
39
+ - `phone_fs_read`:读文件,默认 UTF-8 文本(`maxChars` 默认 20000 会截断),`base64: true` 返回原始字节;大文件用 `offset` + `length` 分块(默认 512 KB,上限 4 MB)。
40
+ - `phone_fs_write`:写文件,文本用 `text`、二进制用 `dataB64`;`truncate` 默认 `true`(覆盖),传 `false` 才续写;父目录自动建。
41
+ - `phone_fs_mkdir` / `phone_fs_rm`:建目录(父目录一起建)/ 删文件或目录(删目录必须 `recursive: true`)。
37
42
  - `phone_disconnect`:从电脑端断开当前会话。
38
43
 
39
44
  ## 坐标建议(iQOO V2425A,截图 1260×2800)
@@ -52,6 +57,8 @@ disable-model-invocation: false
52
57
  | `BAD_CODE` | 配对码错误 | 核对 8 位数字,注意剩余次数 |
53
58
  | `TOKEN_INVALID` | 登录凭证失效 | 手机 App 会清空旧 token,重新配对 |
54
59
  | `TIMEOUT` | 手机未在超时内响应 | 重新截图确认设备是否卡死 |
60
+ | `SELF_PROTECTED` | 输入类命令打到手机桥自己的界面(默认保护) | 先把前台切走;或让用户打开「允许远程操作本 App」 |
61
+ | `PERMISSION_DENIED` | 文件路径越出 App 沙箱,或没开「所有文件访问」 | 让用户在 App 设置页「权限与安全」里授权,或改用 App 私有目录 |
55
62
 
56
63
  ## 典型流程
57
64