dsh-mobile-plugin 0.5.22 → 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/canvas-app.html +73 -72
- package/lib/client.js +18 -19
- package/lib/impl.js +2 -2
- package/lib/mobile/cli/mobile.js +4 -28
- package/lib/mobile/live-steps.js +35 -3
- package/lib/mobile/local-images.js +156 -82
- package/lib/mobile/pairing.js +44 -4
- package/lib/mobile/phone/service.js +75 -10
- package/lib/mobile/phone/tools.js +3 -1
- package/lib/mobile/promo-receiver.cjs +77 -0
- package/lib/mobile/stream.js +8 -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/panel/index.html +15 -14
- package/lib/relay/connection-check.js +55 -0
- package/lib/relay/status.js +2 -2
- package/lib/routes/control-plane.js +4 -12
- package/lib/routes/mobile-api.js +35 -0
- package/lib/tailcat/host-process.cjs +5 -1
- package/lib/tailcat/session.cjs +42 -5
- package/lib/tailcat/wiring.cjs +25 -4
- package/lib/update-install.cjs +47 -38
- package/package.json +1 -1
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
|
|
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()
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
// 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
|
package/lib/mobile/cli/mobile.js
CHANGED
|
@@ -3,34 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* `cli-*` 会话的移动端适配层:让一条 CLI 对话走宿主普通会话那一整套接口。
|
|
5
5
|
*
|
|
6
|
-
* CLI
|
|
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')
|
package/lib/mobile/live-steps.js
CHANGED
|
@@ -48,6 +48,8 @@ function turnOf(ctx, sessionId) {
|
|
|
48
48
|
/** 还没发出去的那几步(`drainLiveSteps` 取走)。 */
|
|
49
49
|
pending: [],
|
|
50
50
|
ended: false,
|
|
51
|
+
/** 这份副本属于哪一轮(宿主 turn 号):换轮就整份作废,见 `turnForEvent`。 */
|
|
52
|
+
turnNo: null,
|
|
51
53
|
};
|
|
52
54
|
bucket.delete(sessionId);
|
|
53
55
|
bucket.set(sessionId, turn);
|
|
@@ -57,6 +59,28 @@ function turnOf(ctx, sessionId) {
|
|
|
57
59
|
return turn;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 把一条事件归到它所属的那一轮;**轮号一变就整份作废**。
|
|
64
|
+
*
|
|
65
|
+
* 为什么要靠事件自带的 `data.turn` 兜这一手:`noteLiveEvent` 只在这条流上收到 `turn/start`
|
|
66
|
+
* 时清桶,可断线重连后那一轮的 `turn/start` 已经在重连之前发生过、被当作「回放已覆盖」丢掉,
|
|
67
|
+
* 于是新的一轮的步骤会接着上一轮往后长,越积越多、最后全挂在最新那条消息下。每一帧增量
|
|
68
|
+
* 都带着它属于哪个 turn,用它来判就不会漏:turn 号变了就把整轮副本换掉(下一轮的步骤从空起)。
|
|
69
|
+
* 同一轮内(turn 号相同)原样沿用。
|
|
70
|
+
*/
|
|
71
|
+
function turnForEvent(ctx, sessionId, data) {
|
|
72
|
+
const bucket = bucketOf(ctx);
|
|
73
|
+
const explicitTurn = data && typeof data.turn === 'number' ? data.turn : null;
|
|
74
|
+
let turn = bucket.get(sessionId);
|
|
75
|
+
if (turn && explicitTurn !== null && turn.turnNo !== null && turn.turnNo !== explicitTurn) {
|
|
76
|
+
bucket.delete(sessionId);
|
|
77
|
+
turn = undefined;
|
|
78
|
+
}
|
|
79
|
+
if (!turn) turn = turnOf(ctx, sessionId);
|
|
80
|
+
if (turn.turnNo === null && explicitTurn !== null) turn.turnNo = explicitTurn;
|
|
81
|
+
return turn;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/** 把一段文本接到已有的那段后面,超过上限就只留尾巴。 */
|
|
61
85
|
function appendText(prev, next) {
|
|
62
86
|
if (typeof next !== 'string' || next === '') return prev || '';
|
|
@@ -160,11 +184,12 @@ function noteLiveEvent(ctx, sessionId, event) {
|
|
|
160
184
|
if (event.type === 'turn/start') {
|
|
161
185
|
// 新的一轮开始:上一轮的过程副本作废(换掉了,就别再让人点开旧内容)。
|
|
162
186
|
bucketOf(ctx).delete(sessionId);
|
|
163
|
-
turnOf(ctx, sessionId);
|
|
187
|
+
const started = turnOf(ctx, sessionId);
|
|
188
|
+
if (data && typeof data.turn === 'number') started.turnNo = data.turn;
|
|
164
189
|
return;
|
|
165
190
|
}
|
|
166
191
|
if (!data) return;
|
|
167
|
-
const turn =
|
|
192
|
+
const turn = turnForEvent(ctx, sessionId, data);
|
|
168
193
|
if (event.type === 'assistant/message') {
|
|
169
194
|
// 一步落定:这一步的思考按块序号收尾(后面那一步的块序号从 0 重新数)。
|
|
170
195
|
closeThoughtsOfStep(turn);
|
|
@@ -303,6 +328,13 @@ function liveStepsOf(ctx, sessionId) {
|
|
|
303
328
|
return turn.steps.map((entry) => ({ key: entry.key, step: { ...entry.step } }));
|
|
304
329
|
}
|
|
305
330
|
|
|
331
|
+
/** 这份直播清单属于哪一轮(宿主 turn 号)——随 `session/live-steps` 帧下发,App 据此按轮隔离。 */
|
|
332
|
+
function liveStepsTurnOf(ctx, sessionId) {
|
|
333
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return null;
|
|
334
|
+
const turn = liveStepTurnOf(ctx).get(sessionId);
|
|
335
|
+
return turn && typeof turn.turnNo === 'number' ? turn.turnNo : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
306
338
|
/**
|
|
307
339
|
* 取走「还没告诉过手机」的那几步。
|
|
308
340
|
*
|
|
@@ -354,4 +386,4 @@ function clearLiveSteps(ctx, sessionId) {
|
|
|
354
386
|
liveStepTurnOf(ctx).delete(sessionId);
|
|
355
387
|
}
|
|
356
388
|
|
|
357
|
-
module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, drainLiveSteps, clearLiveSteps };
|
|
389
|
+
module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, liveStepsTurnOf, drainLiveSteps, clearLiveSteps };
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* 保守取舍——宁可保留字面 markdown,也不给半截东西:
|
|
19
19
|
* - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
|
|
20
20
|
* 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
|
|
21
|
+
* - 双分隔符打头的网页地址(协议相对 `//host/…`)与 Windows 网络 / 扩展路径(`\\host\…`、`\\?\…`)
|
|
22
|
+
* 不碰:它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
|
|
21
23
|
* - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
|
|
22
24
|
* - 宽高只读文件头(最多 64KB);读不出来就不带宽高,App 那边照旧现量;
|
|
23
25
|
* - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
|
|
@@ -85,7 +87,9 @@ function localImagePathOf(url, cwd) {
|
|
|
85
87
|
let value = url.trim();
|
|
86
88
|
if (!value) return null;
|
|
87
89
|
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
|
88
|
-
|
|
90
|
+
// 盘符路径(`C:\…`、`C:/…`)不是 URL 协议:单字母后跟分隔符的别当成 scheme 拦掉。
|
|
91
|
+
const hasUrlScheme = /^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[a-z]:[\\/]/i.test(value);
|
|
92
|
+
if (hasUrlScheme) {
|
|
89
93
|
if (value.slice(0, 7).toLowerCase() !== 'file://') return null;
|
|
90
94
|
try {
|
|
91
95
|
value = decodeURIComponent(value.slice(7));
|
|
@@ -96,6 +100,10 @@ function localImagePathOf(url, cwd) {
|
|
|
96
100
|
} else if (value === '~' || value.startsWith('~/')) {
|
|
97
101
|
value = path.join(os.homedir(), value.slice(1));
|
|
98
102
|
}
|
|
103
|
+
// 双分隔符打头的是网页地址(协议相对 `//host/…`)或 Windows 网络 / 扩展路径(`\\host\…`、
|
|
104
|
+
// `\\?\…`)。它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
|
|
105
|
+
// `file://` 的远程主机经上面剥完也可能落到这里,同样不认。
|
|
106
|
+
if (/^[\\/]{2}/.test(value)) return null;
|
|
99
107
|
if (!path.isAbsolute(value)) {
|
|
100
108
|
if (!cwd) return null;
|
|
101
109
|
value = path.resolve(cwd, value);
|
|
@@ -273,19 +281,154 @@ async function registerLocalImage(filePath) {
|
|
|
273
281
|
const size = sane ? parsed : {};
|
|
274
282
|
const entry = { filePath, mediaType, ...size, ...(over ? { tooLarge: true } : {}) };
|
|
275
283
|
registry.set(id, entry);
|
|
276
|
-
|
|
284
|
+
trimRegistry();
|
|
277
285
|
return { id, ...entry };
|
|
278
286
|
}
|
|
279
287
|
|
|
288
|
+
/** 登记表满了就淘汰最老的一条;取回来的临时文件一并删掉,别在地上留垃圾。 */
|
|
289
|
+
function trimRegistry() {
|
|
290
|
+
while (registry.size > MAX_REGISTRY_ENTRIES) {
|
|
291
|
+
const oldestId = registry.keys().next().value;
|
|
292
|
+
const entry = registry.get(oldestId);
|
|
293
|
+
registry.delete(oldestId);
|
|
294
|
+
if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
/** 取图口用:只认登记表里的编号(构造不出来的编号一律 null → 404)。 */
|
|
281
299
|
function localImageOf(id) {
|
|
282
300
|
if (typeof id !== 'string' || !id) return null;
|
|
283
301
|
return registry.get(id) || null;
|
|
284
302
|
}
|
|
285
303
|
|
|
286
|
-
/**
|
|
304
|
+
/** 清空登记表(测试用)。取回来的临时文件一并删掉。 */
|
|
287
305
|
function clearLocalImages() {
|
|
306
|
+
for (const entry of registry.values()) {
|
|
307
|
+
if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
|
|
308
|
+
}
|
|
288
309
|
registry.clear();
|
|
310
|
+
loopbackUrlCache.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 回环地址的主机名:出现在正文里时指的是「跑插件这台电脑自己」,手机拿着同一个地址
|
|
315
|
+
* 只会指回手机自己。生图插件常把成品图挂在自己电脑上的一个小服务上,回的就是这种地址。
|
|
316
|
+
*/
|
|
317
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1', '::']);
|
|
318
|
+
|
|
319
|
+
/** 取回回环图片的超时:只在投影一条消息时占这一小会儿,取不到就保持字面 markdown。 */
|
|
320
|
+
const LOOPBACK_FETCH_TIMEOUT_MS = 4000;
|
|
321
|
+
/** 失败后多久内不再重试同一地址:翻历史碰上已经关掉的服务,不该每次都干等一次超时。 */
|
|
322
|
+
const LOOPBACK_FAIL_TTL_MS = 30 * 1000;
|
|
323
|
+
/** 同一地址取到过的图片编号;翻历史时直接复用,不必重复取。 */
|
|
324
|
+
const loopbackUrlCache = new Map();
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* `` 里的地址若是**回环地址的 http(s) 图片**,回那条地址;否则 null。
|
|
328
|
+
*
|
|
329
|
+
* 只认回环:别的远程地址一律不碰 —— 那会把「本机图片」变成「替手机随便抓任意网址」,
|
|
330
|
+
* 能力面就从「读这台电脑上的文件」扩成了代理一切出站请求。回环地址连不到外网,
|
|
331
|
+
* 只是这台电脑自己起的服务,与「读本机文件」同一类。
|
|
332
|
+
*/
|
|
333
|
+
function loopbackImageUrlOf(url) {
|
|
334
|
+
if (typeof url !== 'string') return null;
|
|
335
|
+
let value = url.trim();
|
|
336
|
+
if (!value) return null;
|
|
337
|
+
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
|
338
|
+
if (!/^https?:\/\//i.test(value)) return null;
|
|
339
|
+
let parsed;
|
|
340
|
+
try {
|
|
341
|
+
parsed = new URL(value);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
346
|
+
return LOOPBACK_HOSTNAMES.has(hostname) ? value : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 取回的图片字节落成临时文件再登记:取图口按编号读 `filePath`,与磁盘上的图同一条路。
|
|
351
|
+
*
|
|
352
|
+
* 编号取内容哈希:同一张图反复出现只落一份。超过单张上限的照样登记、但标 `tooLarge`
|
|
353
|
+
* (取图口回 413,App 画「图片太大」占位),与磁盘图片那条口径一致。
|
|
354
|
+
*/
|
|
355
|
+
async function registerLocalImageBytes(buffer, mediaType) {
|
|
356
|
+
if (!buffer || buffer.length === 0) return null;
|
|
357
|
+
const id = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 16);
|
|
358
|
+
const known = registry.get(id);
|
|
359
|
+
if (known) return { id, ...known };
|
|
360
|
+
const over = buffer.length > MAX_IMAGE_BYTES;
|
|
361
|
+
const parsed = over ? null : imageSizeOf(buffer);
|
|
362
|
+
const sane = parsed && parsed.width > 0 && parsed.height > 0
|
|
363
|
+
&& parsed.width <= 30000 && parsed.height <= 30000;
|
|
364
|
+
const size = sane ? parsed : {};
|
|
365
|
+
if (over) {
|
|
366
|
+
const entry = { filePath: '', mediaType, ...size, tooLarge: true, fetched: true };
|
|
367
|
+
registry.set(id, entry);
|
|
368
|
+
trimRegistry();
|
|
369
|
+
return { id, ...entry };
|
|
370
|
+
}
|
|
371
|
+
const ext = mediaType === 'image/jpeg' ? '.jpg'
|
|
372
|
+
: mediaType === 'image/gif' ? '.gif'
|
|
373
|
+
: mediaType === 'image/webp' ? '.webp' : '.png';
|
|
374
|
+
let filePath;
|
|
375
|
+
try {
|
|
376
|
+
const dir = path.join(os.tmpdir(), 'dsh-local-images');
|
|
377
|
+
await fs.mkdir(dir, { recursive: true });
|
|
378
|
+
filePath = path.join(dir, `${id}${ext}`);
|
|
379
|
+
await fs.writeFile(filePath, buffer);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const entry = { filePath, mediaType, ...size, fetched: true };
|
|
384
|
+
registry.set(id, entry);
|
|
385
|
+
trimRegistry();
|
|
386
|
+
return { id, ...entry };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 从回环地址取回一张图片:有超时、有大小上限、必须过魔术字节。
|
|
391
|
+
*
|
|
392
|
+
* 取不到 / 不是图 / 太大 / 重定向去别处,一律返回 null,调用方保持字面 markdown ——
|
|
393
|
+
* 宁可显示成字面文本,也不能让一段历史或一帧 SSE 因为一张图取不到而挂掉(与现有口径一致)。
|
|
394
|
+
* 只走一次 GET、不跟随重定向:跟去别处就不再是「回环图片」了。
|
|
395
|
+
*/
|
|
396
|
+
async function fetchLoopbackImage(url) {
|
|
397
|
+
if (typeof fetch !== 'function') return null;
|
|
398
|
+
const hit = loopbackUrlCache.get(url);
|
|
399
|
+
if (hit) {
|
|
400
|
+
if (hit.id && registry.has(hit.id)) return { id: hit.id, ...registry.get(hit.id) };
|
|
401
|
+
if (!hit.id && hit.failedAt && Date.now() - hit.failedAt < LOOPBACK_FAIL_TTL_MS) return null;
|
|
402
|
+
}
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const timer = setTimeout(() => controller.abort(), LOOPBACK_FETCH_TIMEOUT_MS);
|
|
405
|
+
let buffer;
|
|
406
|
+
try {
|
|
407
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
408
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
409
|
+
const declared = Number(res.headers.get('content-length'));
|
|
410
|
+
if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) throw new Error('too large');
|
|
411
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
412
|
+
} catch {
|
|
413
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
414
|
+
return null;
|
|
415
|
+
} finally {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
}
|
|
418
|
+
const mediaType = buffer && buffer.length > 0 && buffer.length <= MAX_IMAGE_BYTES
|
|
419
|
+
? sniffMediaType(buffer)
|
|
420
|
+
: null;
|
|
421
|
+
if (!mediaType) {
|
|
422
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const entry = await registerLocalImageBytes(buffer, mediaType);
|
|
426
|
+
if (!entry) {
|
|
427
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
loopbackUrlCache.set(url, { id: entry.id });
|
|
431
|
+
return entry;
|
|
289
432
|
}
|
|
290
433
|
/**
|
|
291
434
|
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
@@ -382,9 +525,17 @@ async function inlineTextImages(text, budget) {
|
|
|
382
525
|
let cursor = 0;
|
|
383
526
|
for (const m of matches) {
|
|
384
527
|
if (count <= 0) break;
|
|
528
|
+
let entry = null;
|
|
385
529
|
const filePath = localImagePathOf(m[2], pool.cwd);
|
|
386
|
-
if (
|
|
387
|
-
|
|
530
|
+
if (filePath) {
|
|
531
|
+
entry = await registerLocalImage(filePath);
|
|
532
|
+
} else {
|
|
533
|
+
// 第二种「本机图片」:生图插件把成品图挂在自己电脑的一个回环小服务上,回的是
|
|
534
|
+
// `http://127.0.0.1:<端口>/…`。手机拿着它只会指回手机自己,所以由电脑去取回来、
|
|
535
|
+
// 按编号给手机(与磁盘图片同一条路,宽高/缓存/重试全现成)。
|
|
536
|
+
const loopbackUrl = loopbackImageUrlOf(m[2]);
|
|
537
|
+
if (loopbackUrl) entry = await fetchLoopbackImage(loopbackUrl);
|
|
538
|
+
}
|
|
388
539
|
if (!entry) continue;
|
|
389
540
|
count -= 1;
|
|
390
541
|
pool.remaining = count;
|
|
@@ -394,82 +545,5 @@ async function inlineTextImages(text, budget) {
|
|
|
394
545
|
}
|
|
395
546
|
return cursor === 0 ? text : out + text.slice(cursor);
|
|
396
547
|
}
|
|
397
|
-
/**
|
|
398
|
-
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
399
|
-
*
|
|
400
|
-
* 代码块里的 `` 是**写法示例**而不是要画的图。旧实现整段正则替换,
|
|
401
|
-
* 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
|
|
402
|
-
* 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
|
|
403
|
-
* `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
|
|
404
|
-
*/
|
|
405
|
-
function literalRanges(text) {
|
|
406
|
-
const ranges = [];
|
|
407
|
-
const lines = text.split('\n');
|
|
408
|
-
const lineStarts = [];
|
|
409
|
-
let offset = 0;
|
|
410
|
-
for (const line of lines) {
|
|
411
|
-
lineStarts.push(offset);
|
|
412
|
-
offset += line.length + 1;
|
|
413
|
-
}
|
|
414
|
-
let fence = null;
|
|
415
|
-
let fenceStart = -1;
|
|
416
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
417
|
-
const line = lines[index];
|
|
418
|
-
if (fence) {
|
|
419
|
-
const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
|
|
420
|
-
if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
|
|
421
|
-
ranges.push([fenceStart, lineStarts[index] + line.length]);
|
|
422
|
-
fence = null;
|
|
423
|
-
}
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
const open = /^(`{3,}|~{3,})/.exec(line.trim());
|
|
427
|
-
if (!open) continue;
|
|
428
|
-
fence = { char: open[1][0], length: open[1].length };
|
|
429
|
-
fenceStart = lineStarts[index];
|
|
430
|
-
}
|
|
431
|
-
// 没闭合的围栏按 App 的解析口径一直吃到文末。
|
|
432
|
-
if (fence) ranges.push([fenceStart, text.length]);
|
|
433
|
-
|
|
434
|
-
// 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
|
|
435
|
-
let cursor = 0;
|
|
436
|
-
while (cursor < text.length) {
|
|
437
|
-
if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
|
|
438
|
-
cursor += 1;
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
let ticks = 1;
|
|
442
|
-
while (text[cursor + ticks] === '`') ticks += 1;
|
|
443
|
-
let scan = cursor + ticks;
|
|
444
|
-
let closed = -1;
|
|
445
|
-
while (scan < text.length) {
|
|
446
|
-
if (text[scan] !== '`' || inRanges(scan, ranges)) {
|
|
447
|
-
scan += 1;
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
let run = 1;
|
|
451
|
-
while (text[scan + run] === '`') run += 1;
|
|
452
|
-
if (run === ticks) {
|
|
453
|
-
closed = scan + run;
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
456
|
-
scan += run;
|
|
457
|
-
}
|
|
458
|
-
if (closed < 0) {
|
|
459
|
-
cursor += ticks;
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
ranges.push([cursor, closed]);
|
|
463
|
-
cursor = closed;
|
|
464
|
-
}
|
|
465
|
-
return ranges;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function inRanges(index, ranges) {
|
|
469
|
-
for (const [start, end] of ranges) {
|
|
470
|
-
if (index >= start && index < end) return true;
|
|
471
|
-
}
|
|
472
|
-
return false;
|
|
473
|
-
}
|
|
474
548
|
|
|
475
549
|
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages, MAX_IMAGE_BYTES };
|
package/lib/mobile/pairing.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|