dsh-mobile-plugin 0.5.23 → 0.5.26

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 (54) 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/canvas-app.html +73 -72
  19. package/lib/client.js +18 -19
  20. package/lib/impl.js +11 -2
  21. package/lib/mobile/cli/acp.js +3 -2
  22. package/lib/mobile/cli/mobile.js +4 -28
  23. package/lib/mobile/pairing.js +44 -4
  24. package/lib/mobile/phone/confirm.js +189 -0
  25. package/lib/mobile/phone/index.js +21 -1
  26. package/lib/mobile/phone/irreversible.js +146 -0
  27. package/lib/mobile/phone/pacing.js +173 -0
  28. package/lib/mobile/phone/route-hint.js +164 -0
  29. package/lib/mobile/phone/routes-memory.js +133 -0
  30. package/lib/mobile/phone/routes.js +84 -0
  31. package/lib/mobile/phone/service.js +163 -13
  32. package/lib/mobile/phone/tools.js +1190 -62
  33. package/lib/mobile/phone-knowledge.js +301 -0
  34. package/lib/mobile/promo-receiver.cjs +77 -0
  35. package/lib/mobile/schedule.js +64 -7
  36. package/lib/mobile/sessions.js +40 -5
  37. package/lib/mobile/sync-inbox-store.js +181 -0
  38. package/lib/mobile/sync.js +124 -0
  39. package/lib/mobile/transfer-tickets.js +107 -0
  40. package/lib/mobile/wechat.js +26 -1
  41. package/lib/p2p/rendezvous.cjs +106 -0
  42. package/lib/p2p/session.cjs +455 -0
  43. package/lib/p2p/wiring.cjs +93 -0
  44. package/lib/panel/index.html +15 -14
  45. package/lib/relay/connection-check.js +55 -0
  46. package/lib/relay/status.js +4 -2
  47. package/lib/routes/control-plane.js +28 -13
  48. package/lib/routes/mobile-api.js +55 -1
  49. package/lib/shared/e2ee-request.js +27 -7
  50. package/lib/tailcat/host-process.cjs +37 -0
  51. package/lib/tailcat/session.cjs +42 -5
  52. package/lib/tailcat/wiring.cjs +25 -4
  53. package/lib/update-install.cjs +4 -38
  54. package/package.json +4 -2
@@ -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,
@@ -22,7 +22,7 @@
22
22
  * - `{t:'event', kind:'needs-human'|'progress', sessionId, …}` —— 暂只记账,不转发
23
23
  *
24
24
  * 宿主 → 手机:
25
- * - `{t:'op', id, sessionId, action, args}`
25
+ * - `{t:'op', id, sessionId, action, args, timeout}` —— timeout 是这一发自适应算出的预算(ms)
26
26
  * - `{t:'cancel', id}`
27
27
  *
28
28
  * 动作名是封闭的一小张表(见 `ACTIONS`);手机端认不出的动作回 `unsupported`,宿主不会因为
@@ -40,8 +40,13 @@ const { WebSocketServer } = require('ws');
40
40
 
41
41
  /** 一条帧的上限:截图是 base64 塞在 JSON 里的,留足余量;更大的东西手机不会发。 */
42
42
  const MAX_PAYLOAD = 12 * 1024 * 1024;
43
- /** 心跳:手机每 20 秒至少要有一次 ping/pong 往返,否则按掉线处理(等它自己重连)。 */
43
+ /** 心跳间隔:每 20 秒发一次 ping。 */
44
44
  const HEARTBEAT_MS = 20_000;
45
+ /**
46
+ * 连续丢这么多次心跳(约 60 秒)才按掉线处理,对齐手机端 60s 看门狗。
47
+ * 弱网下偶发一两次丢 pong 不算掉线,免得来回假掉线、把在途 op 全抹成 offline。
48
+ */
49
+ const MAX_MISSED_HEARTBEATS = 3;
45
50
  /** 单次 op 的默认预算;每个动作另有自己的一档(见 ACTIONS)。 */
46
51
  const DEFAULT_OP_TIMEOUT_MS = 30_000;
47
52
 
@@ -64,8 +69,24 @@ const ACTIONS = new Map([
64
69
  ['app.list', 20_000],
65
70
  ['wait', 30_000],
66
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],
67
78
  ]);
68
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
+
69
90
  function text(value) {
70
91
  return typeof value === 'string' ? value.trim() : '';
71
92
  }
@@ -85,16 +106,95 @@ function createPhoneService({ authorized, onLog } = {}) {
85
106
  let closed = false;
86
107
  let seq = 0;
87
108
 
109
+ /** 耗时 EWMA 的平滑系数:动作耗时用 0.3,心跳 RTT 用 0.2。 */
110
+ const EWMA_ALPHA = 0.3;
111
+ const RTT_ALPHA = 0.2;
112
+
113
+ function ewma(prev, sample, alpha) {
114
+ if (!prev || !(prev.n > 0) || !Number.isFinite(prev.value)) return { value: sample, n: 1 };
115
+ return { value: prev.value * (1 - alpha) + sample * alpha, n: prev.n + 1 };
116
+ }
117
+
118
+ /** 成功回包按「发送 → 收到」耗时更新 perAction 与全体两类统计。 */
119
+ function recordLatency(link, action, sample) {
120
+ if (!Number.isFinite(sample) || sample < 0) return;
121
+ const lat = link.latency;
122
+ lat.perAction.set(action, ewma(lat.perAction.get(action), sample, EWMA_ALPHA));
123
+ lat.all = ewma(lat.all, sample, EWMA_ALPHA);
124
+ }
125
+
126
+ /**
127
+ * 某动作这一发该给多少预算:估时 est 取自本动作(样本够)或全体,套 est*2.5 + rtt*2 + 1500,
128
+ * 冷启动抬到 seed*1.5 兜底,最后夹进 [max(2500, seed/2), min(120000, max(seed*8, floor))]。
129
+ * `ACTIONS` 里的数字只是这个 seed,不再直接当预算。
130
+ */
131
+ function budgetFor(link, action, args) {
132
+ if (action === 'batch') return budgetForBatch(link, args);
133
+ const seed = ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS;
134
+ const lat = link.latency;
135
+ const pa = lat.perAction.get(action);
136
+ let est = seed;
137
+ if (pa && pa.n >= 2) est = pa.value;
138
+ else if (lat.all && lat.all.n >= 2) est = lat.all.value;
139
+ const rtt = lat.rtt > 0 ? lat.rtt : 0;
140
+ let budget = est * 2.5 + rtt * 2 + 1500;
141
+ const cold = Date.now() - link.connectedAt < 20_000 || !lat.all || lat.all.n < 1;
142
+ if (cold) budget = Math.max(budget, seed * 1.5);
143
+ const floor = Math.max(2500, seed * 0.5);
144
+ const ceil = Math.min(120_000, Math.max(seed * 8, floor));
145
+ return Math.round(Math.min(Math.max(budget, floor), ceil));
146
+ }
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
+
88
179
  const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不影响搬运 */ } };
89
180
 
90
- 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
+ });
91
186
 
92
187
  function nextId() {
93
188
  seq += 1;
94
189
  return `p${seq}`;
95
190
  }
96
191
 
97
- /** 关掉一条连接并结算它的在途 op。 */
192
+ /**
193
+ * 关掉一条连接并结算它的在途 op。
194
+ *
195
+ * 这里不区分动作:`batch` 和单步一样挂在 `link.pending` 上,走到一半断线就连同它的结果一起
196
+ * 以 offline 结算,手机侧那套「整批共用宿主 timeout」不需要宿主特殊对待。
197
+ */
98
198
  function drop(deviceId, reason) {
99
199
  const link = devices.get(deviceId);
100
200
  if (!link) return;
@@ -108,6 +208,7 @@ function createPhoneService({ authorized, onLog } = {}) {
108
208
  });
109
209
  }
110
210
  link.pending.clear();
211
+ try { clearInterval(link.heartbeat); } catch { /* 已经清过了 */ }
111
212
  try { link.socket.close(); } catch { /* 已经断了 */ }
112
213
  }
113
214
 
@@ -122,6 +223,8 @@ function createPhoneService({ authorized, onLog } = {}) {
122
223
  capabilities: hello && Array.isArray(hello.capabilities) ? hello.capabilities.filter((c) => typeof c === 'string') : [],
123
224
  connectedAt: Date.now(),
124
225
  alive: true,
226
+ missed: 0,
227
+ latency: { perAction: new Map(), all: null, rtt: 0 },
125
228
  pending: new Map(),
126
229
  };
127
230
  devices.set(deviceId, link);
@@ -137,6 +240,12 @@ function createPhoneService({ authorized, onLog } = {}) {
137
240
  if (!frame || typeof frame !== 'object') return;
138
241
  if (frame.t === 'pong') {
139
242
  link.alive = true;
243
+ if (Number.isFinite(link.pingAt) && link.pingAt > 0) {
244
+ const sample = Date.now() - link.pingAt;
245
+ const prev = link.latency.rtt;
246
+ link.latency.rtt = prev > 0 ? prev * (1 - RTT_ALPHA) + sample * RTT_ALPHA : sample;
247
+ link.pingAt = 0;
248
+ }
140
249
  return;
141
250
  }
142
251
  if (frame.t === 'hello') {
@@ -151,8 +260,12 @@ function createPhoneService({ authorized, onLog } = {}) {
151
260
  const pending = link.pending.get(text(frame.id));
152
261
  if (!pending) return;
153
262
  link.pending.delete(text(frame.id));
154
- if (frame.ok) pending.settleOk(frame.data === undefined ? null : frame.data);
155
- else {
263
+ if (frame.ok) {
264
+ if (pending.action && Number.isFinite(pending.sentAt)) {
265
+ recordLatency(link, pending.action, Date.now() - pending.sentAt);
266
+ }
267
+ pending.settleOk(frame.data === undefined ? null : frame.data);
268
+ } else {
156
269
  const error = frame.error && typeof frame.error === 'object' ? frame.error : {};
157
270
  pending.settleErr({
158
271
  code: text(error.code) || 'failed',
@@ -179,12 +292,19 @@ function createPhoneService({ authorized, onLog } = {}) {
179
292
  link.heartbeat = setInterval(() => {
180
293
  if (!statusOf(socket)) return;
181
294
  if (!link.alive) {
182
- log(`phone ${deviceId}: 心跳丢了,按断线处理`);
183
- drop(deviceId, 'offline');
184
- return;
295
+ link.missed += 1;
296
+ if (link.missed >= MAX_MISSED_HEARTBEATS) {
297
+ log(`phone ${deviceId}: 连续 ${link.missed} 次心跳没回应,按断线处理`);
298
+ drop(deviceId, 'offline');
299
+ return;
300
+ }
301
+ log(`phone ${deviceId}: 心跳丢了(${link.missed}/${MAX_MISSED_HEARTBEATS}),再等等`);
302
+ } else {
303
+ link.missed = 0;
185
304
  }
186
305
  link.alive = false;
187
- try { socket.send(JSON.stringify({ t: 'ping', at: Date.now() })); } catch { /* 下面 close 会来 */ }
306
+ link.pingAt = Date.now();
307
+ try { socket.send(JSON.stringify({ t: 'ping', at: link.pingAt })); } catch { /* 下面 close 会来 */ }
188
308
  }, HEARTBEAT_MS);
189
309
  link.heartbeat.unref?.();
190
310
 
@@ -210,7 +330,7 @@ function createPhoneService({ authorized, onLog } = {}) {
210
330
  if (!ACTIONS.has(action)) return Promise.reject({ code: 'unknown-action', message: `不支持的动作 ${action}` });
211
331
 
212
332
  const opId = nextId();
213
- const budget = Number.isFinite(timeoutMs) ? timeoutMs : (ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS);
333
+ const budget = Number.isFinite(timeoutMs) ? timeoutMs : budgetFor(link, action, args);
214
334
  return new Promise((resolve, reject) => {
215
335
  let settled = false;
216
336
  const settle = (fn, value) => {
@@ -222,9 +342,13 @@ function createPhoneService({ authorized, onLog } = {}) {
222
342
  fn(value);
223
343
  };
224
344
  const pending = {
345
+ action,
346
+ sentAt: Date.now(),
225
347
  settleOk: (data) => settle(resolve, { ok: true, data }),
226
348
  settleErr: (error) => settle(reject, error),
227
349
  };
350
+ // 超时 / 取消都发同一条 `cancel` 帧 —— 帧里只带 id,手机侧自己按 id 找那条 op,
351
+ // 所以正在跑的 `batch` 也能被这一步撤销,不需要另加批专用的停止帧。
228
352
  const timer = setTimeout(() => {
229
353
  settle(reject, { code: 'timeout', message: '迟迟没有回应这一步' });
230
354
  try { link.socket.send(JSON.stringify({ t: 'cancel', id: opId })); } catch { /* 已断 */ }
@@ -240,7 +364,7 @@ function createPhoneService({ authorized, onLog } = {}) {
240
364
  }
241
365
  link.pending.set(opId, pending);
242
366
  try {
243
- link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {} }));
367
+ link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {}, timeout: budget }));
244
368
  } catch {
245
369
  settle(reject, { code: 'offline', message: '发不出去,那台手机断开了' });
246
370
  }
@@ -253,6 +377,20 @@ function createPhoneService({ authorized, onLog } = {}) {
253
377
  return Boolean(link && statusOf(link.socket));
254
378
  }
255
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
+
256
394
  /** 一台手机的连接信息(给 App 的状态页用)。 */
257
395
  function statusOfDevice(deviceId) {
258
396
  const link = devices.get(text(deviceId));
@@ -302,7 +440,19 @@ function createPhoneService({ authorized, onLog } = {}) {
302
440
  wss.close?.();
303
441
  }
304
442
 
305
- 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
+ };
306
456
  }
307
457
 
308
458
  function refuse(socket, status, reason, bodyText) {