dsh-mobile-plugin 0.5.24 → 0.5.27

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.
Files changed (42) hide show
  1. package/README.md +6 -6
  2. package/knowledge/apps/alipay.md +30 -0
  3. package/knowledge/apps/android-settings.md +30 -0
  4. package/knowledge/apps/bilibili.md +30 -0
  5. package/knowledge/apps/boss-zhipin.md +32 -0
  6. package/knowledge/apps/douyin.md +32 -0
  7. package/knowledge/apps/taobao.md +33 -0
  8. package/knowledge/apps/wechat.md +33 -0
  9. package/knowledge/apps/weibo.md +31 -0
  10. package/knowledge/apps/xiaohongshu.md +29 -0
  11. package/knowledge/apps/zhihu.md +30 -0
  12. package/knowledge/sites/bilibili.com.md +27 -0
  13. package/knowledge/sites/github.com.md +27 -0
  14. package/knowledge/sites/x.com.md +28 -0
  15. package/knowledge/sites/xiaohongshu.com.md +27 -0
  16. package/knowledge/sites/youtube.com.md +28 -0
  17. package/knowledge/sites/zhihu.com.md +27 -0
  18. package/lib/impl.js +9 -0
  19. package/lib/mobile/cli/acp.js +3 -2
  20. package/lib/mobile/phone/confirm.js +189 -0
  21. package/lib/mobile/phone/index.js +21 -1
  22. package/lib/mobile/phone/irreversible.js +146 -0
  23. package/lib/mobile/phone/pacing.js +173 -0
  24. package/lib/mobile/phone/route-hint.js +164 -0
  25. package/lib/mobile/phone/routes-memory.js +133 -0
  26. package/lib/mobile/phone/routes.js +84 -0
  27. package/lib/mobile/phone/service.js +90 -5
  28. package/lib/mobile/phone/tools.js +1190 -64
  29. package/lib/mobile/phone-knowledge.js +301 -0
  30. package/lib/mobile/schedule.js +64 -7
  31. package/lib/mobile/sessions.js +40 -5
  32. package/lib/mobile/wechat.js +26 -1
  33. package/lib/p2p/rendezvous.cjs +118 -0
  34. package/lib/p2p/session.cjs +595 -0
  35. package/lib/p2p/wiring.cjs +117 -0
  36. package/lib/panel/index.html +10 -10
  37. package/lib/relay/status.js +2 -0
  38. package/lib/routes/control-plane.js +24 -1
  39. package/lib/routes/mobile-api.js +20 -1
  40. package/lib/shared/e2ee-request.js +27 -7
  41. package/lib/tailcat/host-process.cjs +54 -1
  42. package/package.json +4 -2
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `phone_route_hint`:只读地翻出「这个 App 以前成功过的路线」。
5
+ *
6
+ * 用法是打开一个 App 之后问一句「以前有没有做成过、怎么做的」。**只给参考,不自动执行**:
7
+ * 这里既不连手机、也不发任何动作,模型看过之后还得自己按当前屏幕重新判断。之所以只给摘要不给
8
+ * 原始步骤,是因为界面会变,照抄旧路线比没有更危险 —— 摘要让模型看懂大意,具体每一步仍要现读屏。
9
+ *
10
+ * 注册形状照 `phone/tools.js` 里的那套:走 `ctx.inject(['tools'], …)` 再 `defineTool`,
11
+ * 不能直接读 `ctx.tools`(宿主里那是个影子对象,`register` 一调就抛,被容错吞掉后一条工具都交不到
12
+ * 模型手上)。缺 tools 注入的 profile 静默跳过,别把整块装配掀翻。
13
+ */
14
+
15
+ const routesMemory = require('./routes-memory.js');
16
+
17
+ /** 一次最多回几条路线。 */
18
+ const MAX_ROUTES = 5;
19
+ /** 摘要里单段文字最长多少字。 */
20
+ const MAX_TEXT = 60;
21
+
22
+ /** 动作名换成一句人话动词。 */
23
+ const ACTION_VERBS = {
24
+ tap: '点击',
25
+ type: '输入',
26
+ key: '按键',
27
+ swipe: '滑动',
28
+ gesture: '手势',
29
+ 'screen.wait': '等待',
30
+ 'screen.read': '读屏',
31
+ 'screen.screenshot': '截图',
32
+ 'app.open': '打开',
33
+ 'app.close': '关闭',
34
+ };
35
+
36
+ function clip(text) {
37
+ const value = typeof text === 'string' ? text.trim() : '';
38
+ if (!value) return '';
39
+ return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value;
40
+ }
41
+
42
+ /**
43
+ * 把一步压成一句摘要。只取最有辨识度的一个参数,不要把小箭头变成一段代码。
44
+ */
45
+ function summarizeStep(step) {
46
+ if (!step || typeof step !== 'object') return '';
47
+ const action = typeof step.action === 'string' ? step.action.trim() : '';
48
+ if (!action) return '';
49
+ const args = step.args && typeof step.args === 'object' ? step.args : {};
50
+ const verb = ACTION_VERBS[action] || action;
51
+
52
+ let object = '';
53
+ if (action === 'type') {
54
+ const text = clip(args.text);
55
+ if (text) object = `「${text}」`;
56
+ } else if (action === 'tap') {
57
+ const target = clip(args.target);
58
+ if (target) object = `「${target}」`;
59
+ else if (typeof args.ref === 'string' && args.ref.trim()) object = args.ref.trim();
60
+ } else if (action === 'key') {
61
+ object = typeof args.key === 'string' ? args.key : '';
62
+ } else if (action === 'app.open') {
63
+ object = clip(args.packageName);
64
+ } else if (action === 'swipe') {
65
+ object = typeof args.direction === 'string' ? args.direction : '';
66
+ } else {
67
+ object = clip(args.target) || clip(args.text) || clip(args.packageName) || (typeof args.ref === 'string' ? args.ref.trim() : '');
68
+ }
69
+ return object ? `${verb}${object}` : verb;
70
+ }
71
+
72
+ /**
73
+ * 把一条路线压成可读的摘要对象:步骤数、时间、整条路线的一句话。
74
+ */
75
+ function summarizeRoute(route) {
76
+ const steps = route && Array.isArray(route.steps) ? route.steps : [];
77
+ const parts = steps.map(summarizeStep).filter(Boolean);
78
+ return {
79
+ at: route && Number.isFinite(route.at) ? route.at : 0,
80
+ stepCount: steps.length,
81
+ summary: parts.join(' → '),
82
+ };
83
+ }
84
+
85
+ /** 取这个 App 最近成功的几条,压成摘要。没有记录回空数组。 */
86
+ function hintFor(packageName) {
87
+ const routes = routesMemory.list(packageName);
88
+ // 最近的先给:新近成功的路线更贴近现在这个版本的界面。
89
+ return routes
90
+ .slice()
91
+ .sort((a, b) => b.at - a.at)
92
+ .slice(0, MAX_ROUTES)
93
+ .map(summarizeRoute);
94
+ }
95
+
96
+ /**
97
+ * 注册 `phone_route_hint`。与 `installPhoneTools` 同一口径:拿不到 tools 注入就静静不装。
98
+ */
99
+ function installRouteHint(ctx) {
100
+ if (!ctx || typeof ctx.inject !== 'function') return;
101
+ let defineTool;
102
+ try {
103
+ ({ defineTool } = require('@deepseek-ai/dsh-tools'));
104
+ } catch {
105
+ return;
106
+ }
107
+
108
+ ctx.inject(['tools'], (scoped) => {
109
+ if (!scoped || !scoped.tools || typeof scoped.tools.register !== 'function') return;
110
+ scoped.tools.register(defineTool({
111
+ name: 'phone_route_hint',
112
+ description: 'Look up routes this app has succeeded with before, as short step summaries. This is reference only — nothing is executed. After opening an app, use it to recall a past successful path, then re-read the screen and decide each step yourself; the UI may have changed.',
113
+ parameters: {
114
+ packageName: {
115
+ type: 'string',
116
+ required: true,
117
+ description: 'Android package name of the app, e.g. "com.tencent.mm".',
118
+ },
119
+ },
120
+ output: {
121
+ schema: {
122
+ type: 'object',
123
+ additionalProperties: false,
124
+ properties: {
125
+ ok: { type: 'boolean' },
126
+ packageName: { type: 'string' },
127
+ count: { type: 'number' },
128
+ // 一条路线是「步骤数 + 摘要」,形状固定,但为了紧凑允许附加字段。
129
+ routes: { type: 'array', items: { type: 'object', additionalProperties: true } },
130
+ note: { type: 'string' },
131
+ },
132
+ },
133
+ render: (_args, value) => {
134
+ const count = Array.isArray(value.routes) ? value.routes.length : 0;
135
+ const lines = [`这个 App 以前成功过 ${count} 条路线(仅供参考,不会自动执行):`];
136
+ for (const route of value.routes || []) lines.push(`- ${route.summary}`);
137
+ if (!count) lines.push('- 还没有记录。');
138
+ return [{ type: 'text', text: lines.join('\n') }];
139
+ },
140
+ },
141
+ execute: async (args) => {
142
+ const pkg = typeof args.packageName === 'string' ? args.packageName.trim() : '';
143
+ if (!pkg) throw new Error('要给一个包名(例如 com.tencent.mm)才能查历史路线。');
144
+ const routes = hintFor(pkg);
145
+ const value = {
146
+ ok: true,
147
+ packageName: pkg,
148
+ count: routes.length,
149
+ routes,
150
+ };
151
+ if (routes.length === 0) value.note = '这个 App 还没有成功过的路线记录。';
152
+ return value;
153
+ },
154
+ }));
155
+ });
156
+ }
157
+
158
+ module.exports = {
159
+ installRouteHint,
160
+ summarizeStep,
161
+ summarizeRoute,
162
+ hintFor,
163
+ MAX_ROUTES,
164
+ };
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 成功路线记忆:把「这个 App 上一轮是怎么一步步做成的」记下来,下次打开同一个 App 时能翻出
5
+ * 以前成功过的路线给模型参考。
6
+ *
7
+ * 存一份 JSON 在 `<DSH_HOME>/storages/mobile-plugin/phone-routes.json`,按包名分桶:
8
+ *
9
+ * { "<包名>": [ { steps: [...], at: 1794…, hits: 0 } ] } // 每个包最多 20 条
10
+ *
11
+ * 三条边界:
12
+ * - **只在成功时记**:`record` 由调用方在结局判定为成功后调;失败、超时、取消一律不记。
13
+ * - **每个包封顶 20 条**:旧记录满了就挤掉最旧的,避免一个常用 App 把文件越攒越大。
14
+ * - **加载失败安静退化**:文件不存在、读坏、形状不对,都当「还没有记录」,绝不抛 —— 记忆是
15
+ * 锦上添花,不能因为它把一次操作或者整个插件装载掀翻。
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { dshHome } = require('../../shared/paths');
22
+
23
+ /** 每个包名最多留这么多条历史路线。 */
24
+ const MAX_PER_PACKAGE = 20;
25
+
26
+ /** 落盘文件:`<DSH_HOME>/storages/mobile-plugin/phone-routes.json`,调用时解析。 */
27
+ function routesFile() {
28
+ return path.join(dshHome(), 'storages', 'mobile-plugin', 'phone-routes.json');
29
+ }
30
+
31
+ /** 只保留能落盘、也有意义的字段,别把整个 args 原样塞进去(可能很大)。 */
32
+ function normalizeSteps(steps) {
33
+ if (!Array.isArray(steps)) return [];
34
+ const out = [];
35
+ for (const step of steps) {
36
+ if (!step || typeof step !== 'object') continue;
37
+ const entry = {};
38
+ if (typeof step.action === 'string' && step.action.trim()) entry.action = step.action.trim();
39
+ if (step.args && typeof step.args === 'object' && !Array.isArray(step.args)) entry.args = step.args;
40
+ if (step.until && typeof step.until === 'object' && !Array.isArray(step.until)) entry.until = step.until;
41
+ if (entry.action) out.push(entry);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** 读整份账本。读不到 / 读坏 / 形状不对都当空。 */
47
+ function readStore() {
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(routesFile(), 'utf8');
51
+ } catch {
52
+ return {};
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ } catch {
58
+ return {};
59
+ }
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
61
+ return parsed;
62
+ }
63
+
64
+ /** 写整份账本。写不进去就安静放弃:这一轮照样算完成,只是这条没记住。 */
65
+ function writeStore(store) {
66
+ const file = routesFile();
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`);
69
+ }
70
+
71
+ /** 归一化包名。 */
72
+ function normalizePackage(packageName) {
73
+ return typeof packageName === 'string' ? packageName.trim() : '';
74
+ }
75
+
76
+ /**
77
+ * 记一条成功路线。只在成功时由调用方调;包名为空或步骤为空时不记。
78
+ * 返回这个包当前的全部记录(方便调用方/测试看结果)。
79
+ */
80
+ function record(packageName, steps) {
81
+ const pkg = normalizePackage(packageName);
82
+ const cleaned = normalizeSteps(steps);
83
+ if (!pkg || cleaned.length === 0) return [];
84
+
85
+ const store = readStore();
86
+ const bucket = Array.isArray(store[pkg]) ? store[pkg].filter((item) => item && Array.isArray(item.steps)) : [];
87
+ bucket.push({ steps: cleaned, at: Date.now(), hits: 0 });
88
+ // 只留最近的 MAX_PER_PACKAGE 条:新记录在尾部,超了从头挤掉最旧的。
89
+ store[pkg] = bucket.slice(-MAX_PER_PACKAGE);
90
+ try {
91
+ writeStore(store);
92
+ } catch {
93
+ // 写失败不抛,记忆丢一条不影响这一轮已经成功的结果。
94
+ }
95
+ return store[pkg].map(copyRecord);
96
+ }
97
+
98
+ function copyRecord(item) {
99
+ return {
100
+ steps: Array.isArray(item.steps) ? item.steps.map((step) => ({ ...step })) : [],
101
+ at: Number.isFinite(item.at) ? item.at : 0,
102
+ hits: Number.isFinite(item.hits) ? item.hits : 0,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 读回某个 App 以前成功过的路线(按记录先后,旧的在前)。包名为空或没记录都回空数组。
108
+ * 返回的是副本,调用方改不到账本上。
109
+ */
110
+ function list(packageName) {
111
+ const pkg = normalizePackage(packageName);
112
+ if (!pkg) return [];
113
+ const store = readStore();
114
+ const bucket = Array.isArray(store[pkg]) ? store[pkg] : [];
115
+ return bucket.filter((item) => item && Array.isArray(item.steps)).map(copyRecord);
116
+ }
117
+
118
+ /** 单测用:删掉落盘文件,回到「还没有任何记录」。 */
119
+ function resetForTest() {
120
+ try {
121
+ fs.rmSync(routesFile(), { force: true });
122
+ } catch {
123
+ // 本来就没有。
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ routesFile,
129
+ record,
130
+ list,
131
+ resetForTest,
132
+ MAX_PER_PACKAGE,
133
+ };
@@ -20,6 +20,9 @@
20
20
 
21
21
  const { sendJson, readJsonBody } = require('../../shared/http-util');
22
22
  const binding = require('./binding.js');
23
+ const pacing = require('./pacing.js');
24
+ const routeHint = require('./route-hint.js');
25
+ const knowledge = require('../phone-knowledge.js');
23
26
 
24
27
  /** 设备标题:机型优先,没有机型才用 deviceId 的短尾巴。与凭据面板同一口径。 */
25
28
  function deviceLabel(row) {
@@ -60,6 +63,9 @@ function listDevices(deps) {
60
63
  osVersion: row.osVersion || '',
61
64
  appVersion: row.appVersion || '',
62
65
  online: Boolean(live),
66
+ // 能力清单只对「此刻连着」的那台有意义:设置页据此说「这台手机支持什么」。
67
+ // 没连上时报空表,界面那边按「还没连上」处理,不拿上一次的旧清单冒充。
68
+ capabilities: live && Array.isArray(live.capabilities) ? live.capabilities : [],
63
69
  lastSeenAt: row.lastSeenAt || 0,
64
70
  });
65
71
  if (live) online.delete(row.deviceId);
@@ -74,6 +80,7 @@ function listDevices(deps) {
74
80
  osVersion: device.osVersion || '',
75
81
  appVersion: device.appVersion || '',
76
82
  online: true,
83
+ capabilities: Array.isArray(link.capabilities) ? link.capabilities : [],
77
84
  lastSeenAt: Date.now(),
78
85
  });
79
86
  }
@@ -105,6 +112,12 @@ async function handlePhoneStatus(deps, _ctx, req, res) {
105
112
  lastSender: binding.lastSenderOf(sessionId) || '',
106
113
  };
107
114
  }
115
+ // 顶层再带一份「这台手机能做什么」:设置页问的是「我现在操作的是哪台、它能做什么」,
116
+ // 那条会话绑了就看绑的那台,没绑就看唯第一台在线的。都没有就是空表。
117
+ const focus = (payload.binding && payload.binding.deviceId)
118
+ ? devices.find((row) => row.deviceId === payload.binding.deviceId)
119
+ : devices.find((row) => row.online);
120
+ payload.capabilities = focus && Array.isArray(focus.capabilities) ? focus.capabilities : [];
108
121
  sendJson(res, 200, payload);
109
122
  }
110
123
 
@@ -141,6 +154,71 @@ async function handlePhoneUnbind(deps, _ctx, req, res) {
141
154
  sendJson(res, 200, { ok: true, clearedSession: false, clearedBindings: cleared, disconnected: targets, localReset: 'requested' });
142
155
  }
143
156
 
157
+ /**
158
+ * GET /phone/knowledge?package=… —— 知识库里有哪些 App、有哪些网站,各自包名 / 域名,以及按就
159
+ * 包名查询时的命中情况。
160
+ *
161
+ * 只读:把 `knowledge/apps/*.md` 与 `knowledge/sites/*.md` 的目录清单摊平给设置页看,不碰手机、
162
+ * 不发任何动作。`package` 是可选的前台包名;给了就标出哪一条 App 命中(`hit`),并回填 `matched`。
163
+ */
164
+ async function handlePhoneKnowledge(deps, _ctx, req, res) {
165
+ const url = new URL(req.url || '/', 'http://x');
166
+ const pkg = (url.searchParams.get('package') || '').trim();
167
+ const matched = pkg ? knowledge.matchApp(pkg) : null;
168
+ const apps = knowledge.listApps().map((app) => ({
169
+ name: app.name,
170
+ display: app.display,
171
+ packages: app.packages,
172
+ aliases: app.aliases,
173
+ open: app.open,
174
+ hit: Boolean(matched && matched.name === app.name),
175
+ }));
176
+ const sites = knowledge.listSites().map((site) => ({
177
+ name: site.name,
178
+ display: site.display,
179
+ description: site.description,
180
+ sites: site.sites,
181
+ aliases: site.aliases,
182
+ }));
183
+ sendJson(res, 200, { ok: true, apps, sites, matched: matched ? matched.name : '' });
184
+ }
185
+
186
+ /**
187
+ * GET /phone/routes?package=… —— 某个 App 以前成功过的路线(最多 5 条,只给步骤摘要)。
188
+ *
189
+ * 只读:从成功路线账本里读回,不碰手机、不发任何动作。**这些只是参考,不会自动执行** ——
190
+ * 模型看过之后仍要按当前屏重新判断。
191
+ */
192
+ async function handlePhoneRoutes(_deps, _ctx, req, res) {
193
+ const url = new URL(req.url || '/', 'http://x');
194
+ const pkg = (url.searchParams.get('package') || '').trim();
195
+ const routes = pkg ? routeHint.hintFor(pkg) : [];
196
+ sendJson(res, 200, { ok: true, packageName: pkg, count: routes.length, routes });
197
+ }
198
+
199
+ /**
200
+ * GET / POST /phone/pacing —— 读或改「操作节奏」那一档。
201
+ *
202
+ * 和上面几条一样只碰设置,不往手机上发动作:节奏改了下一轮动作才生效,正在跑的那一批
203
+ * 不跟着变(动作中途改停留会让边界很奇怪,重开一轮才干净)。
204
+ */
205
+ async function handlePhonePacing(_deps, _ctx, req, res) {
206
+ if (req.method === 'GET' || !req.method) {
207
+ const mode = pacing.get();
208
+ return sendJson(res, 200, { ok: true, mode, pace: pacing.paceFor(mode) });
209
+ }
210
+ const body = await readJsonBody(req);
211
+ const wanted = body && typeof body.mode === 'string' ? body.mode.trim().toLowerCase() : '';
212
+ if (!wanted) return sendJson(res, 400, { ok: false, error: 'missing-mode' });
213
+ try {
214
+ const mode = pacing.set(wanted);
215
+ sendJson(res, 200, { ok: true, mode, pace: pacing.paceFor(mode) });
216
+ } catch {
217
+ // 不认识的档名:如实回 400 并把可选值带上,别让设置页自己维护一份枚举。
218
+ sendJson(res, 400, { ok: false, error: 'unknown-mode', modes: pacing.MODES });
219
+ }
220
+ }
221
+
144
222
  /**
145
223
  * 造出路由表要的那组 handler。
146
224
  *
@@ -154,6 +232,9 @@ function createPhoneRoutes(deps) {
154
232
  status: (ctx, req, res) => handlePhoneStatus(deps, ctx, req, res),
155
233
  devices: (ctx, req, res) => handlePhoneDevices(deps, ctx, req, res),
156
234
  unbind: (ctx, req, res) => handlePhoneUnbind(deps, ctx, req, res),
235
+ knowledge: (ctx, req, res) => handlePhoneKnowledge(deps, ctx, req, res),
236
+ routes: (ctx, req, res) => handlePhoneRoutes(deps, ctx, req, res),
237
+ pacing: (ctx, req, res) => handlePhonePacing(deps, ctx, req, res),
157
238
  };
158
239
  }
159
240
 
@@ -162,6 +243,9 @@ module.exports = {
162
243
  handlePhoneStatus,
163
244
  handlePhoneDevices,
164
245
  handlePhoneUnbind,
246
+ handlePhoneKnowledge,
247
+ handlePhoneRoutes,
248
+ handlePhonePacing,
165
249
  listDevices,
166
250
  describeDevice,
167
251
  deviceLabel,
@@ -69,8 +69,24 @@ const ACTIONS = new Map([
69
69
  ['app.list', 20_000],
70
70
  ['wait', 30_000],
71
71
  ['task.release', 20_000],
72
+ // `batch` 是多个动作打包:这里的数字只是它作为「一个动作」登记进表时的占位 seed,
73
+ // 真实预算由 budgetForBatch 按步数与等待上限单独算(见下),不走单动作那套。
74
+ ['batch', 120_000],
75
+ ['screen.wait', 30_000],
76
+ ['gesture', 20_000],
77
+ ['app.link', 20_000],
72
78
  ]);
73
79
 
80
+ /**
81
+ * 帧压缩的测量开关,默认关。
82
+ *
83
+ * 宿主端 deflate 是「CPU 换带宽」:手机常在弱网,压缩看着划算,但每一帧都要在宿主上现算,
84
+ * 低端机与高频小帧下 CPU 代价明显,而这条通道的大头(截图 base64)早就一帧一封。到底值不值
85
+ * 要真机量过 p50/p99 延迟与宿主的占用才能定,所以默认维持原样关闭,只留一个环境变量把压缩
86
+ * 打开来测量,不擅自改线上默认。
87
+ */
88
+ const COMPRESS_FRAMES = process.env.DSH_MOBILE_PHONE_COMPRESS === '1';
89
+
74
90
  function text(value) {
75
91
  return typeof value === 'string' ? value.trim() : '';
76
92
  }
@@ -112,7 +128,8 @@ function createPhoneService({ authorized, onLog } = {}) {
112
128
  * 冷启动抬到 seed*1.5 兜底,最后夹进 [max(2500, seed/2), min(120000, max(seed*8, floor))]。
113
129
  * `ACTIONS` 里的数字只是这个 seed,不再直接当预算。
114
130
  */
115
- function budgetFor(link, action) {
131
+ function budgetFor(link, action, args) {
132
+ if (action === 'batch') return budgetForBatch(link, args);
116
133
  const seed = ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS;
117
134
  const lat = link.latency;
118
135
  const pa = lat.perAction.get(action);
@@ -128,16 +145,56 @@ function createPhoneService({ authorized, onLog } = {}) {
128
145
  return Math.round(Math.min(Math.max(budget, floor), ceil));
129
146
  }
130
147
 
148
+ /**
149
+ * `batch` 的预算要按整批放,不能沿用单动作的估法 —— 一批十几步,每步都可能带一段等待,
150
+ * 单步预算乘一乘就把手机端「整批共用宿主下发 timeout」的约定撑破了。
151
+ *
152
+ * 累加三样:每步各自的估时(有实测 EWMA 就用实测,没有就用该动作的 seed),每步 `until`
153
+ * 声明的等待上限(与手机侧同口径:默认 8000、封顶 20000),以及每步一次往返的网络余量。
154
+ * 结果夹在 [5s, 180s]:低于 5s 连正常几步都装不下;高于 180s 基本是手机卡死,再等没意义。
155
+ */
156
+ function budgetForBatch(link, args) {
157
+ const steps = args && Array.isArray(args.steps) ? args.steps : [];
158
+ const lat = link.latency;
159
+ const rtt = lat.rtt > 0 ? lat.rtt : 0;
160
+ let total = 0;
161
+ for (const step of steps) {
162
+ const action = text(step && step.action);
163
+ const seed = ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS;
164
+ const pa = lat.perAction.get(action);
165
+ let est = seed;
166
+ if (pa && pa.n >= 2) est = pa.value;
167
+ else if (lat.all && lat.all.n >= 2) est = lat.all.value;
168
+ total += est;
169
+ const until = step && step.until;
170
+ if (until && typeof until === 'object') {
171
+ const t = Number(until.timeoutMs);
172
+ total += Number.isFinite(t) && t > 0 ? Math.min(t, 20_000) : 8_000;
173
+ }
174
+ }
175
+ total += rtt * 2 * steps.length;
176
+ return Math.round(Math.min(Math.max(total, 5_000), 180_000));
177
+ }
178
+
131
179
  const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不影响搬运 */ } };
132
180
 
133
- const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
181
+ const wss = new WebSocketServer({
182
+ noServer: true,
183
+ perMessageDeflate: COMPRESS_FRAMES ? { threshold: 1024 } : false,
184
+ maxPayload: MAX_PAYLOAD,
185
+ });
134
186
 
135
187
  function nextId() {
136
188
  seq += 1;
137
189
  return `p${seq}`;
138
190
  }
139
191
 
140
- /** 关掉一条连接并结算它的在途 op。 */
192
+ /**
193
+ * 关掉一条连接并结算它的在途 op。
194
+ *
195
+ * 这里不区分动作:`batch` 和单步一样挂在 `link.pending` 上,走到一半断线就连同它的结果一起
196
+ * 以 offline 结算,手机侧那套「整批共用宿主 timeout」不需要宿主特殊对待。
197
+ */
141
198
  function drop(deviceId, reason) {
142
199
  const link = devices.get(deviceId);
143
200
  if (!link) return;
@@ -273,7 +330,7 @@ function createPhoneService({ authorized, onLog } = {}) {
273
330
  if (!ACTIONS.has(action)) return Promise.reject({ code: 'unknown-action', message: `不支持的动作 ${action}` });
274
331
 
275
332
  const opId = nextId();
276
- const budget = Number.isFinite(timeoutMs) ? timeoutMs : budgetFor(link, action);
333
+ const budget = Number.isFinite(timeoutMs) ? timeoutMs : budgetFor(link, action, args);
277
334
  return new Promise((resolve, reject) => {
278
335
  let settled = false;
279
336
  const settle = (fn, value) => {
@@ -290,6 +347,8 @@ function createPhoneService({ authorized, onLog } = {}) {
290
347
  settleOk: (data) => settle(resolve, { ok: true, data }),
291
348
  settleErr: (error) => settle(reject, error),
292
349
  };
350
+ // 超时 / 取消都发同一条 `cancel` 帧 —— 帧里只带 id,手机侧自己按 id 找那条 op,
351
+ // 所以正在跑的 `batch` 也能被这一步撤销,不需要另加批专用的停止帧。
293
352
  const timer = setTimeout(() => {
294
353
  settle(reject, { code: 'timeout', message: '迟迟没有回应这一步' });
295
354
  try { link.socket.send(JSON.stringify({ t: 'cancel', id: opId })); } catch { /* 已断 */ }
@@ -318,6 +377,20 @@ function createPhoneService({ authorized, onLog } = {}) {
318
377
  return Boolean(link && statusOf(link.socket));
319
378
  }
320
379
 
380
+ /**
381
+ * 这台手机报没报某项能力(工具层据此决定某个参数是否生效)。
382
+ *
383
+ * 手机在 hello / attached 帧里上行 `capabilities`,没连上或没报过就一律当作不支持 ——
384
+ * 「没听说」和「不支持」在这里是同一件事,宁可让工具层多说一句「需要升级手机上的 App」,
385
+ * 也不要凭空假定它能做。
386
+ */
387
+ function hasCapability(deviceId, capName) {
388
+ const link = devices.get(text(deviceId));
389
+ if (!link || !statusOf(link.socket)) return false;
390
+ const name = text(capName);
391
+ return name !== '' && Array.isArray(link.capabilities) && link.capabilities.includes(name);
392
+ }
393
+
321
394
  /** 一台手机的连接信息(给 App 的状态页用)。 */
322
395
  function statusOfDevice(deviceId) {
323
396
  const link = devices.get(text(deviceId));
@@ -367,7 +440,19 @@ function createPhoneService({ authorized, onLog } = {}) {
367
440
  wss.close?.();
368
441
  }
369
442
 
370
- return { handleUpgrade, call, isOnline, statusOfDevice, kick, close, ACTIONS, devices };
443
+ return {
444
+ handleUpgrade,
445
+ call,
446
+ isOnline,
447
+ statusOfDevice,
448
+ hasCapability,
449
+ kick,
450
+ close,
451
+ ACTIONS,
452
+ devices,
453
+ // 测量开关的当前取值,供自检 / 日志读出(真实压缩在 wss 构造时就定了)。
454
+ compressionEnabled: COMPRESS_FRAMES,
455
+ };
371
456
  }
372
457
 
373
458
  function refuse(socket, status, reason, bodyText) {