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 +13 -5
- package/index.js +132 -0
- package/lib/client.js +89 -1
- package/lib/routes.js +23 -0
- package/package.json +3 -2
- package/skills/phone-control/SKILL.md +8 -1
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,
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|