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.
- package/README.md +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/canvas-app.html +73 -72
- package/lib/client.js +18 -19
- package/lib/impl.js +11 -2
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/cli/mobile.js +4 -28
- package/lib/mobile/pairing.js +44 -4
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +163 -13
- package/lib/mobile/phone/tools.js +1190 -62
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/promo-receiver.cjs +77 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/sync-inbox-store.js +181 -0
- package/lib/mobile/sync.js +124 -0
- package/lib/mobile/transfer-tickets.js +107 -0
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +106 -0
- package/lib/p2p/session.cjs +455 -0
- package/lib/p2p/wiring.cjs +93 -0
- package/lib/panel/index.html +15 -14
- package/lib/relay/connection-check.js +55 -0
- package/lib/relay/status.js +4 -2
- package/lib/routes/control-plane.js +28 -13
- package/lib/routes/mobile-api.js +55 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +37 -0
- package/lib/tailcat/session.cjs +42 -5
- package/lib/tailcat/wiring.cjs +25 -4
- package/lib/update-install.cjs +4 -38
- 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
|
-
/**
|
|
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({
|
|
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
|
-
/**
|
|
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)
|
|
155
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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 : (
|
|
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 {
|
|
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) {
|