dsh-mobile-plugin 0.5.23 → 0.5.24

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/lib/client.js CHANGED
@@ -104,7 +104,7 @@ window.__ModuleLoader__.load({
104
104
  * 浏览器侧的应用路由一律写成**文档相对**形式(没有前导斜杠),与 DSH 自己的客户端
105
105
  * 一致:宿主给 index 注入了 `<base href="./">`,页面挂在哪个前缀下,请求就跟着哪个
106
106
  * 前缀走。写成 `/api/...` 这种站点根绝对地址,DSH 被反向代理挂到子路径(例如
107
- * `/dsh/`)时会直接打到站点根、拿回 404 —— 现场看到的正是「读取宿主状态失败: HTTP 404」。
107
+ * `/dsh/`)时会直接打到站点根、拿回 404。
108
108
  */
109
109
  const DESKTOP_VIEW_ENDPOINT = 'api/relay/desktop-view';
110
110
 
@@ -739,8 +739,7 @@ window.__ModuleLoader__.load({
739
739
  }
740
740
  if (spec.title) overlay.setAttribute('aria-label', spec.title);
741
741
  // 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
742
- // **`children` 是 HTMLCollection,没有 `slice()`**(2026-11-05 真机现场:点图标一点反应都
743
- // 没有,控制台一条 TypeError 就把它吞了 —— 假元素的 `children` 是数组,测试里从没红过)。
742
+ // **`children` 是 HTMLCollection,没有 `slice()`**(假元素是数组,测试覆盖不到);
744
743
  // 先摊成数组再边删边走。
745
744
  for (const child of Array.from(overlay.children || [])) child.remove();
746
745
  const card = spec.wide
@@ -931,8 +930,8 @@ window.__ModuleLoader__.load({
931
930
  *
932
931
  * 为什么不能只读一次 `/status`:那条接口自己也在窗口门后面(`WEB_ACCESS_ROUTES`),
933
932
  * 窗口关着时**不是本机**的浏览器只拿得到 423。而客户端原先规定「没地址就不去开窗口」,
934
- * 两边互相锁死 —— 远程用户点了只会看到「网页地址还没就绪」,没有任何顺序能走通
935
- * (2026-11-03 用户报的就是这个)。所以读不到地址时正确的动作不是放弃,而是**先开窗**:
933
+ * 两边互相锁死 —— 远程用户点了只会看到「网页地址还没就绪」,没有任何顺序能走通。
934
+ * 所以读不到地址时正确的动作不是放弃,而是**先开窗**:
936
935
  * 那条开关接口本来就对同源页面豁免,开完再读一次,地址与类别就都有了。
937
936
  *
938
937
  * 地址优先用宿主给的 `panelUrl`,拿不到再用页面自己的同源地址(见 `sameOriginPanelUrl`)。
@@ -1043,7 +1042,7 @@ window.__ModuleLoader__.load({
1043
1042
  /** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
1044
1043
  const FOOTER_URL_RETRY_MS = 20000;
1045
1044
  /**
1046
- * 手机图标(用户 2026-11-02 从八个候选里挑的「刘海 + 底条」那一款):24 画布描边,
1045
+ * 手机图标(「刘海 + 底条」那一款):24 画布描边,
1047
1046
  * 机身 6.2→17.8、圆角 2.8,顶上一条刘海、底下一条家条。比早先那版机身更满一点,
1048
1047
  * 因为窄高的形状按名义尺寸画出来会比旁边的方形图标显小。
1049
1048
  */
@@ -1053,7 +1052,7 @@ window.__ModuleLoader__.load({
1053
1052
  'M11 19.4h2',
1054
1053
  ];
1055
1054
  /**
1056
- * 告警态那颗红点(用户 2026-11-05 从四个候选里拍的「整颗转红 + 右上角一颗点」那一款)。
1055
+ * 告警态那颗红点(「整颗转红 + 右上角一颗点」那一款)。
1057
1056
  *
1058
1057
  * 圆心在机身右上角外侧:左缘压住右上那圈圆角(机身右边到 17.8),两块同色连成一整块,
1059
1058
  * 所以不用再抠一圈背景色的缝隙。24 画布内放得下——最右到 22,不越界。
@@ -1062,11 +1061,11 @@ window.__ModuleLoader__.load({
1062
1061
  */
1063
1062
  const PHONE_ALERT_DOT = { cx: 18.9, cy: 5, r: 3.1 };
1064
1063
  /**
1065
- * 图标边长:展开那一行 20,窄条里的圆钮 22(用户 2026-11-02 拍的,先 18 后嫌小又抬了一档)。
1064
+ * 图标边长:展开那一行 20,窄条里的圆钮 22。
1066
1065
  *
1067
1066
  * 那一行的账号按钮高 44、里面真正画出来的只有 24 的头像,同行其它图标是 16。20 是
1068
1067
  * 28 的按钮里还能四边各留 4 像素的上限;窄条那颗放进 36 的圆钮里,用 22。
1069
- * 别再往回调 16 / 18:手机图形窄,按名义尺寸画出来比同行的方形图标显小,用户两次都说小。
1068
+ * 别再往回调 16 / 18:手机图形窄,按名义尺寸画出来比同行的方形图标显小。
1070
1069
  * 真要再大,得连按钮一起撑到 30 以上——那一行其它按钮都是 28,会开始抢。
1071
1070
  */
1072
1071
  const PHONE_GLYPH_WIDE = 20;
@@ -1116,8 +1115,8 @@ window.__ModuleLoader__.load({
1116
1115
  * 把「打开哪一页 / 现在能不能点」同步到元素上。
1117
1116
  *
1118
1117
  * 没有地址时**不写 href**(没有地址可写,别挂死链),但**点击处理照样挂上**:那颗图标的
1119
- * 点击会自己去把两小时窗口开出来、再读一次地址(见 `openConfigurationPage`)。原先这里
1120
- * 把 `onclick` 一起摘掉,现场就是「点了完全没反应」——2026-11-03 用户报的正是这个。
1118
+ * 点击会自己去把两小时窗口开出来、再读一次地址(见 `openConfigurationPage`)。别把
1119
+ * `onclick` 一起摘掉 —— 摘了就是「点了完全没反应」。
1121
1120
  *
1122
1121
  * 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
1123
1122
  */
@@ -1172,7 +1171,7 @@ window.__ModuleLoader__.load({
1172
1171
  * 就画在 `sidebar.footer.action` 自己那一行(居中摞在设置上方)——这一条是物理限制,
1173
1172
  * 不是退路。认不出那一行的时候也走这条路,入口不会消失。
1174
1173
  *
1175
- * 点下去做什么(2026-11-02 用户拍板):宿主在 `/status` 上按请求 Host 报一个
1174
+ * 点下去做什么:宿主在 `/status` 上按请求 Host 报一个
1176
1175
  * `panelScope`,图标据此分流:
1177
1176
  * - `loopback`:人就在这台机器的 DSH 里,控制面本来就认它 —— 普通链接跳转;
1178
1177
  * - `private` / `public`:浏览器是从别的机器来的,控制面不认它、面板会回 423,
@@ -1253,9 +1252,9 @@ window.__ModuleLoader__.load({
1253
1252
  * 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
1254
1253
  */
1255
1254
  /**
1256
- * 点下去做什么(2026-11-05 用户要求改过一次)。
1255
+ * 点下去做什么。
1257
1256
  *
1258
- * **从「点了就跳浏览器」改成「点了先在这一页弹一屏」**:用户遇到问题时最缺的不是
1257
+ * **点了先在这一页弹一屏**(不是直接跳浏览器):用户遇到问题时最缺的不是
1259
1258
  * 跳过去,而是「它现在到底哪一环断了」——启动、装载、隧道、旁挂进程、自检全都在那一屏
1260
1259
  * 里,比一个打不开的页面有用。跳转本身没取消,挪进了那一屏里的一颗按钮。
1261
1260
  *
@@ -1344,9 +1343,9 @@ window.__ModuleLoader__.load({
1344
1343
  };
1345
1344
 
1346
1345
  /**
1347
- * 第一层:**只有一个简单入口**(2026-11-05 用户要求)。
1346
+ * 第一层:**只有一个简单入口**。
1348
1347
  *
1349
- * 原来点图标一上来就是整屏读数,太重了 —— 绝大多数时候人只是想去配置页,或者去看
1348
+ * 一上来就整屏读数太重 —— 绝大多数时候人只是想去配置页,或者去看
1350
1349
  * 手机为什么连不上。所以这一层只摆两块:左边「进入控制台」(= 原来那个跳转),
1351
1350
  * 右边「报错调试」(才进第二层那一屏)。
1352
1351
  */
@@ -1796,9 +1795,9 @@ window.__ModuleLoader__.load({
1796
1795
  }
1797
1796
 
1798
1797
  /* ── 设置里的那一项「移动端」───────────────────────────────────────────
1799
- * 用户 2026-11-03 要求加回来:介绍产品时说的入口就是「设置 → 移动端」,2026-11-02 那一版
1800
- * 把它整块下线、只留侧边栏那颗图标,于是从网页版进来的人不知道配置在哪。现在两项都在,
1801
- * 它们进的是同一个页面、走同一条拨号链路(图标是快捷方式,这一张是设置里的正规入口)。
1798
+ * 设置里的「移动端」与侧边栏那颗图标进的是同一个页面、走同一条拨号链路(图标是快捷方式,
1799
+ * 这一张是设置里的正规入口):介绍产品时说的入口就是「设置 → 移动端」,只留图标会让从
1800
+ * 网页版进来的人不知道配置在哪。
1802
1801
  *
1803
1802
  * 刻意只有一张薄卡:界面本体全在网页版里(A3-37),在这里再画一套等于长期维护第二套 UI,
1804
1803
  * 而两套迟早会打架。卡片只做两件事——说清配置在哪、把那一页开出来;开不出来就在卡上写原因。
package/lib/impl.js CHANGED
@@ -32,7 +32,7 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
32
32
  // 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
33
33
  const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
- const { mountTailcat } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
35
+ const { mountTailcat, chooseTailcatTarget } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
36
36
  const { createPreviewService } = require('./relay/preview.js')
37
37
  const { mountTerminal, mountCli } = require('./mobile/services.js')
38
38
  // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
@@ -304,7 +304,7 @@ function apply(ctx) {
304
304
  const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
305
305
  const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
306
306
  // 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
307
- const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, lanPortInUse, hostName: () => state.name, ctx, name })
307
+ const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, targetPort: chooseTailcatTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), hostName: () => state.name, ctx, name })
308
308
  // 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
309
309
  // lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
310
310
  // 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
@@ -3,34 +3,10 @@
3
3
  /**
4
4
  * `cli-*` 会话的移动端适配层:让一条 CLI 对话走宿主普通会话那一整套接口。
5
5
  *
6
- * CLI 会话不是宿主的会话 —— 它是一条 ACP 子进程,块表在 `cli/blocks.js` 里。但对手机来说
7
- * 它必须长得跟普通会话一模一样:出现在侧栏列表里、点进去有历史、能发、能停、能选模型、
8
- * 会弹审批卡。所以这一层只做两个方向的翻译,不新造任何接口:
9
- *
10
- * 1. **请求方向**:`sessionId` 以 `cli-` 开头的,路由层先看一眼再交给这里(见
11
- * `lib/routes/mobile-api.js`)。`sessionId` 在查询串(GET)或请求体(POST)里,所以
12
- * 分派只发生一次,不抄任何 handler。
13
- * 2. **帧方向**:ACP 的块表 → 宿主那套 `session/event` 帧。App 认哪几种、缺哪个就转圈,
14
- * 是按 `app/src/hooks/streamFrame.ts` 与 `app/src/utils/frameWatermark.ts` 逐条对出来的:
15
- * `turn/start` / `turn/end` / `user/message` / `assistant/chunk` / `tool/result` /
16
- * `approval/*` / `session/title` / `session/heartbeat`。
17
- *
18
- * 三条刻意的减法(都比着「App 到底靠什么落地」做的,不是省事):
19
- *
20
- * - **正文与工具的过程用草稿帧,落定的内容一律走历史。** 草稿(`assistant/chunk`)不需要
21
- * seq、不会被序门丢掉;一轮结束先发 `turn/end`(reason=completed),再把这一轮每个块
22
- * 以稳定 id 发一条 `assistant/message`。id 与 `/session/messages` 的行 id 是同一个
23
- * (块的 id),所以重连 / resync 按 id 合并,不会出现两遍。`assistant/message` 放在
24
- * `turn/end` **之后**:App 在「当前回合」收尾时会把这条消息当成终局消息再收一次尾,
25
- * 先发 turn/end 就只是就地更新,不会提前把这一轮判成结束。
26
- * - **审批卡照抄宿主那一种形状**(帧级 `rpcId` + payload `approvalId`,见 `approvals.js`)。
27
- * App 只认这种卡;新造一种等于卡片永远不出现。回答仍走 `/approval/respond`,在这一层
28
- * 按 rpcId 找回 ACP 的挂起项。
29
- * - **拿不到的字段给合理缺省,不报错。** 模型目录拿不到就回只有一项的清单;统计、队列、
30
- * 搜索这类一律回空;这条会话在每种接口上都可用,比「主链路通了、点别的报错」重要。
31
- *
32
- * 会话本身仍归 `cli/service.js` 托管(进程、块表、审批挂起项都在那儿),这里只拿它的
33
- * 只读门面。
6
+ * CLI 会话不是宿主会话,而是一条 ACP 子进程(块表在 `cli/blocks.js`),但对手机必须长得跟普通会话一样:侧栏有它、点进去有历史、能发能停能选模型会弹审批卡。所以这一层只做两个方向的翻译、不新造接口:
7
+ * 请求方向:`sessionId` 以 `cli-` 开头时路由层先看一眼再交给这里(见 `lib/routes/mobile-api.js`),分派只发生一次。
8
+ * 帧方向:ACP 块表 → 宿主 `session/event` 帧(帧种类按 App 实际认的对齐)。
9
+ * 三条刻意减法:过程走草稿帧(`assistant/chunk`)、落定内容一律走历史(`assistant/message` 放在 `turn/end` 之后);审批卡照抄宿主形状(帧级 `rpcId` + payload `approvalId`,见 `approvals.js`);拿不到的字段给合理缺省不报错。会话本身仍归 `cli/service.js` 托管。
34
10
  */
35
11
 
36
12
  const fs = require('node:fs')
@@ -12,6 +12,11 @@
12
12
  * 请求体两种形状都认:老 App 的 `{ instanceId?, code, device }` 明文照旧;协议 v2 的
13
13
  * `{ instanceId, sealed }` 没有配对码,票据本身就是资格证明,回包同样是密文 —— 里面是
14
14
  * token / cookie / 设备凭据,明文回包等于让中转拿到这台设备的长期通行证。
15
+ *
16
+ * 协议 v2 的请求体还多了个可选 `ticketId`(批量同步导入用,见 docs/device-batch-sync.md):
17
+ * 有它就从一次性券表(`mobile/transfer-tickets.js`)取那张券当票据,配成功后 `consume` 作废;
18
+ * 没有就沿用 `state.pairingTicket`(电脑面板二维码那条路,行为不变)。券由导出方手机向目标
19
+ * 电脑申领、塞进二维码,所以票据不再只能是面板上那一张 —— 这是本文件相对旧签名的唯一变化。
15
20
  */
16
21
 
17
22
  const { sendJson } = require('../shared/http-util');
@@ -103,10 +108,11 @@ function readBody(req) {
103
108
  * @param {(port: number) => Array<object>} [deps.lanCandidates] 直连候选(与 `/connection-info` 同一份)
104
109
  * @param {(port: number) => Array<object>} [deps.ipv6Candidates] IPv6 直连候选
105
110
  * @param {() => number} [deps.lanPort] 宿主实绑的直连端口(配置端口被占时会换)
111
+ * @param {object} [deps.transferTickets] 一次性券表(transfer-tickets.js);有它才认 `ticketId`
106
112
  * @param {(message: string) => void} deps.log
107
113
  */
108
114
  function createPairHandler(deps) {
109
- const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, log } = deps;
115
+ const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, transferTickets, log } = deps;
110
116
  /**
111
117
  * 配对成功之后换掉配对码与票据(§5.1)。
112
118
  *
@@ -168,9 +174,34 @@ function createPairHandler(deps) {
168
174
  let supplied;
169
175
  let issuedExtra;
170
176
  let pairKey = null;
177
+ // 这次配对若用的是一次性券,记下它的 ticketId,配成功后立刻作废;面板票据那条路留 null
178
+ // (它靠 rotatePairing 换码换票据,不是靠这里 consume)。
179
+ let ticketIdToConsume = null;
171
180
  if (isSealedShape) {
181
+ // 票据来源:带 ticketId 就从一次性券表取(批量同步导入);否则沿用面板那张 state 票据。
182
+ const ticketId = typeof body.ticketId === 'string' ? body.ticketId.trim() : '';
183
+ let ticket = state.pairingTicket;
184
+ if (ticketId) {
185
+ ticket = transferTickets && typeof transferTickets.resolve === 'function'
186
+ ? transferTickets.resolve(instanceId, ticketId)
187
+ : null;
188
+ // 券不存在 / 过期 / 已用:与「配对码错误」同形拒绝,不给探测空间。
189
+ // 券不存在 / 过期 / 已用:与「配对码错误」不同,这里可以点明 —— 券是不可猜的随机串
190
+ // (不像六位配对码能逐位试),明说「用过或过期,请另一台手机重新导出」不给出探测空间,
191
+ // 又能让批量导入方一眼知道该干什么,而不是对着一句「配对码错误」去重扫。
192
+ if (!ticket) {
193
+ throttle.noteFail();
194
+ sendJson(res, 401, {
195
+ ok: false,
196
+ code: 'transfer-ticket-invalid',
197
+ error: '这个二维码已被使用或已过期,请让导出方那台手机重新导出后再扫',
198
+ });
199
+ return;
200
+ }
201
+ ticketIdToConsume = ticketId;
202
+ }
172
203
  const opened = openSealedPairRequest({
173
- ticket: state.pairingTicket,
204
+ ticket,
174
205
  instanceId,
175
206
  sealed,
176
207
  claimedInstanceId: typeof body.instanceId === 'string' ? body.instanceId : '',
@@ -225,8 +256,17 @@ function createPairHandler(deps) {
225
256
  return;
226
257
  }
227
258
 
228
- // 成功签发之后立刻换码 + 换票据:谁截了二维码,那张图从这一刻起就没有用了。
229
- if (rotatePairing) {
259
+ // 成功签发之后立刻作废资格:
260
+ // · 用一次性券配的 → consume 那张券(同一张配不了第二台手机);面板的码与票据不动,
261
+ // 因为这次配对根本没用到它们、也不该让别人的面板二维码跟着失效。
262
+ // · 面板二维码那条老路 → 换码 + 换票据(谁截了那张图,从这一刻起就没有用了)。
263
+ if (ticketIdToConsume && transferTickets && typeof transferTickets.consume === 'function') {
264
+ try {
265
+ transferTickets.consume(instanceId, ticketIdToConsume);
266
+ } catch (error) {
267
+ log('pair ticket consume failed:', error && error.message);
268
+ }
269
+ } else if (rotatePairing) {
230
270
  try {
231
271
  rotatePairing();
232
272
  } catch (error) {
@@ -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
 
@@ -85,6 +90,44 @@ function createPhoneService({ authorized, onLog } = {}) {
85
90
  let closed = false;
86
91
  let seq = 0;
87
92
 
93
+ /** 耗时 EWMA 的平滑系数:动作耗时用 0.3,心跳 RTT 用 0.2。 */
94
+ const EWMA_ALPHA = 0.3;
95
+ const RTT_ALPHA = 0.2;
96
+
97
+ function ewma(prev, sample, alpha) {
98
+ if (!prev || !(prev.n > 0) || !Number.isFinite(prev.value)) return { value: sample, n: 1 };
99
+ return { value: prev.value * (1 - alpha) + sample * alpha, n: prev.n + 1 };
100
+ }
101
+
102
+ /** 成功回包按「发送 → 收到」耗时更新 perAction 与全体两类统计。 */
103
+ function recordLatency(link, action, sample) {
104
+ if (!Number.isFinite(sample) || sample < 0) return;
105
+ const lat = link.latency;
106
+ lat.perAction.set(action, ewma(lat.perAction.get(action), sample, EWMA_ALPHA));
107
+ lat.all = ewma(lat.all, sample, EWMA_ALPHA);
108
+ }
109
+
110
+ /**
111
+ * 某动作这一发该给多少预算:估时 est 取自本动作(样本够)或全体,套 est*2.5 + rtt*2 + 1500,
112
+ * 冷启动抬到 seed*1.5 兜底,最后夹进 [max(2500, seed/2), min(120000, max(seed*8, floor))]。
113
+ * `ACTIONS` 里的数字只是这个 seed,不再直接当预算。
114
+ */
115
+ function budgetFor(link, action) {
116
+ const seed = ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS;
117
+ const lat = link.latency;
118
+ const pa = lat.perAction.get(action);
119
+ let est = seed;
120
+ if (pa && pa.n >= 2) est = pa.value;
121
+ else if (lat.all && lat.all.n >= 2) est = lat.all.value;
122
+ const rtt = lat.rtt > 0 ? lat.rtt : 0;
123
+ let budget = est * 2.5 + rtt * 2 + 1500;
124
+ const cold = Date.now() - link.connectedAt < 20_000 || !lat.all || lat.all.n < 1;
125
+ if (cold) budget = Math.max(budget, seed * 1.5);
126
+ const floor = Math.max(2500, seed * 0.5);
127
+ const ceil = Math.min(120_000, Math.max(seed * 8, floor));
128
+ return Math.round(Math.min(Math.max(budget, floor), ceil));
129
+ }
130
+
88
131
  const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不影响搬运 */ } };
89
132
 
90
133
  const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
@@ -108,6 +151,7 @@ function createPhoneService({ authorized, onLog } = {}) {
108
151
  });
109
152
  }
110
153
  link.pending.clear();
154
+ try { clearInterval(link.heartbeat); } catch { /* 已经清过了 */ }
111
155
  try { link.socket.close(); } catch { /* 已经断了 */ }
112
156
  }
113
157
 
@@ -122,6 +166,8 @@ function createPhoneService({ authorized, onLog } = {}) {
122
166
  capabilities: hello && Array.isArray(hello.capabilities) ? hello.capabilities.filter((c) => typeof c === 'string') : [],
123
167
  connectedAt: Date.now(),
124
168
  alive: true,
169
+ missed: 0,
170
+ latency: { perAction: new Map(), all: null, rtt: 0 },
125
171
  pending: new Map(),
126
172
  };
127
173
  devices.set(deviceId, link);
@@ -137,6 +183,12 @@ function createPhoneService({ authorized, onLog } = {}) {
137
183
  if (!frame || typeof frame !== 'object') return;
138
184
  if (frame.t === 'pong') {
139
185
  link.alive = true;
186
+ if (Number.isFinite(link.pingAt) && link.pingAt > 0) {
187
+ const sample = Date.now() - link.pingAt;
188
+ const prev = link.latency.rtt;
189
+ link.latency.rtt = prev > 0 ? prev * (1 - RTT_ALPHA) + sample * RTT_ALPHA : sample;
190
+ link.pingAt = 0;
191
+ }
140
192
  return;
141
193
  }
142
194
  if (frame.t === 'hello') {
@@ -151,8 +203,12 @@ function createPhoneService({ authorized, onLog } = {}) {
151
203
  const pending = link.pending.get(text(frame.id));
152
204
  if (!pending) return;
153
205
  link.pending.delete(text(frame.id));
154
- if (frame.ok) pending.settleOk(frame.data === undefined ? null : frame.data);
155
- else {
206
+ if (frame.ok) {
207
+ if (pending.action && Number.isFinite(pending.sentAt)) {
208
+ recordLatency(link, pending.action, Date.now() - pending.sentAt);
209
+ }
210
+ pending.settleOk(frame.data === undefined ? null : frame.data);
211
+ } else {
156
212
  const error = frame.error && typeof frame.error === 'object' ? frame.error : {};
157
213
  pending.settleErr({
158
214
  code: text(error.code) || 'failed',
@@ -179,12 +235,19 @@ function createPhoneService({ authorized, onLog } = {}) {
179
235
  link.heartbeat = setInterval(() => {
180
236
  if (!statusOf(socket)) return;
181
237
  if (!link.alive) {
182
- log(`phone ${deviceId}: 心跳丢了,按断线处理`);
183
- drop(deviceId, 'offline');
184
- return;
238
+ link.missed += 1;
239
+ if (link.missed >= MAX_MISSED_HEARTBEATS) {
240
+ log(`phone ${deviceId}: 连续 ${link.missed} 次心跳没回应,按断线处理`);
241
+ drop(deviceId, 'offline');
242
+ return;
243
+ }
244
+ log(`phone ${deviceId}: 心跳丢了(${link.missed}/${MAX_MISSED_HEARTBEATS}),再等等`);
245
+ } else {
246
+ link.missed = 0;
185
247
  }
186
248
  link.alive = false;
187
- try { socket.send(JSON.stringify({ t: 'ping', at: Date.now() })); } catch { /* 下面 close 会来 */ }
249
+ link.pingAt = Date.now();
250
+ try { socket.send(JSON.stringify({ t: 'ping', at: link.pingAt })); } catch { /* 下面 close 会来 */ }
188
251
  }, HEARTBEAT_MS);
189
252
  link.heartbeat.unref?.();
190
253
 
@@ -210,7 +273,7 @@ function createPhoneService({ authorized, onLog } = {}) {
210
273
  if (!ACTIONS.has(action)) return Promise.reject({ code: 'unknown-action', message: `不支持的动作 ${action}` });
211
274
 
212
275
  const opId = nextId();
213
- const budget = Number.isFinite(timeoutMs) ? timeoutMs : (ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS);
276
+ const budget = Number.isFinite(timeoutMs) ? timeoutMs : budgetFor(link, action);
214
277
  return new Promise((resolve, reject) => {
215
278
  let settled = false;
216
279
  const settle = (fn, value) => {
@@ -222,6 +285,8 @@ function createPhoneService({ authorized, onLog } = {}) {
222
285
  fn(value);
223
286
  };
224
287
  const pending = {
288
+ action,
289
+ sentAt: Date.now(),
225
290
  settleOk: (data) => settle(resolve, { ok: true, data }),
226
291
  settleErr: (error) => settle(reject, error),
227
292
  };
@@ -240,7 +305,7 @@ function createPhoneService({ authorized, onLog } = {}) {
240
305
  }
241
306
  link.pending.set(opId, pending);
242
307
  try {
243
- link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {} }));
308
+ link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {}, timeout: budget }));
244
309
  } catch {
245
310
  settle(reject, { code: 'offline', message: '发不出去,那台手机断开了' });
246
311
  }
@@ -63,6 +63,8 @@ async function run(service, deviceId, sessionId, action, args, signal) {
63
63
  if (code === 'offline') throw new Error('那台手机在这期间断开了连接,这一步没有做。');
64
64
  if (code === 'timeout') throw new Error(`那台手机 ${message}。可以再试一次,或者先 phone_screen 看看它现在停在哪。`);
65
65
  if (code === 'unsupported') throw new Error(`那台手机上装的 App 版本还不支持 ${action},需要把手机上的 App 升级到最新。`);
66
+ if (code === 'adb-unavailable') throw new Error('那台手机的无线调试没连上(可能被系统关了),让用户在手机上重新打开无线调试。');
67
+ if (code === 'warming') throw new Error('手机正在准备后台屏,大约还要几秒,稍等再试。');
66
68
  if (code === 'aborted') throw new Error('这一步被取消了。');
67
69
  throw new Error(message);
68
70
  }
@@ -246,7 +248,7 @@ function installPhoneTools(ctx, deps) {
246
248
  try {
247
249
  ref = await store.saveImage({
248
250
  data: Buffer.from(blob, 'base64'),
249
- mediaType: 'image/png',
251
+ mediaType: data && data.format === 'jpeg' ? 'image/jpeg' : 'image/png',
250
252
  name: textOf(args.target, MAX_TEXT) || 'phone-screen',
251
253
  });
252
254
  } catch (error) {
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 商店素材接收端(本机那个 Mac-to-eagle 桌面程序)的坐标。
5
+ *
6
+ * 为什么由插件来报:用户在手机 App 里不该填任何东西(2026-11-07 明确要求)。而「这台电脑的
7
+ * 局域网地址 + 桌面程序的配对密钥」只有插件这一侧知道 —— 它本来就跑在这台电脑上,局域网候选
8
+ * 地址(`lan-network`)和桌面程序写下的密钥文件都在本机,读一下就能把两样一起交给手机。
9
+ *
10
+ * 桌面程序不在跑 / 没装 / 还没生成密钥,就回 null:App 那边据此什么也不做(原图照旧留相册),
11
+ * 不会因为「这台电脑上没有它」而报错。
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ /** 桌面程序的默认端口(见 Mac-to-eagle `LocalAPIServer.preferredPort`)。 */
19
+ const DESKTOP_PORT = 47291;
20
+
21
+ /** 密钥文件:桌面程序用 owner-only 权限写在应用数据目录里。 */
22
+ function accessKeyFile() {
23
+ // 与 Mac-to-eagle `AppPaths.appSupport` 同一套规则:环境变量优先,其次用户应用数据目录。
24
+ const override = process.env.MACTOEAGLE_DATA_DIR;
25
+ const base = override && override.startsWith('/')
26
+ ? override
27
+ : path.join(os.homedir(), 'Library', 'Application Support', 'MacToEagle');
28
+ return path.join(base, 'mobile-access-key');
29
+ }
30
+
31
+ /** 读配对密钥;读不到就 null(不是错误,只说明这台电脑上没有那个程序)。 */
32
+ function readAccessKey() {
33
+ try {
34
+ const value = fs.readFileSync(accessKeyFile(), 'utf8').trim();
35
+ return value || null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 这台电脑上、手机能拨到的那个局域网地址。
43
+ *
44
+ * 直接用 `lan-network` 那套候选(它已经滤掉了 VPN / 虚拟网卡、把私网排在前面),取第一条的
45
+ * 主机部分 —— 素材回传只需要一个能连通的地址,不需要像会话通道那样挨个试。
46
+ */
47
+ function pickLanHost(lanCandidates) {
48
+ const first = Array.isArray(lanCandidates) ? lanCandidates.find((item) => item && item.url) : null;
49
+ if (!first) return null;
50
+ try {
51
+ return new URL(first.url).hostname;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 组装给 App 的那一格。
59
+ *
60
+ * @param {{ lanCandidates?: Array<{url?: string}> }} options 与 `/connection-info` 同一份候选
61
+ * @returns {{ url: string, key: string } | null}
62
+ */
63
+ function promoReceiver({ lanCandidates } = {}) {
64
+ const key = readAccessKey();
65
+ if (!key) return null;
66
+ const host = pickLanHost(lanCandidates);
67
+ if (!host) return null;
68
+ return { url: `http://${host}:${DESKTOP_PORT}`, key };
69
+ }
70
+
71
+ module.exports = {
72
+ DESKTOP_PORT,
73
+ accessKeyFile,
74
+ readAccessKey,
75
+ pickLanHost,
76
+ promoReceiver,
77
+ };