dsh-mobile-plugin 0.4.4 → 0.4.6
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 +3 -2
- package/lib/desktop-half-stats.cjs +1 -1
- package/lib/git/exec.js +1 -1
- package/lib/host-guard.cjs +1 -1
- package/lib/host-patches.cjs +2 -2
- package/lib/impl.js +6 -7
- package/lib/index.js +2 -2
- package/lib/mirror/pusher.cjs +2 -2
- package/lib/mobile/events.js +3 -3
- package/lib/mobile/host-bridge/event-synthesis.js +15 -1
- package/lib/mobile/host-bridge/shims.js +2 -2
- package/lib/mobile/inbox-clean.js +3 -2
- package/lib/mobile/relay-control.js +28 -0
- package/lib/mobile/session-delivery.js +68 -0
- package/lib/mobile/session-log.js +4 -0
- package/lib/mobile/session-snapshot.js +1 -1
- package/lib/mobile/stream.js +4 -0
- package/lib/mobile/wire-cut.js +24 -15
- package/lib/panel/index.html +6 -6
- package/lib/relay/apply-ledger.cjs +2 -3
- package/lib/relay/core.js +10 -11
- package/lib/relay/lan-network.cjs +3 -3
- package/lib/relay/lan-proxy.js +4 -3
- package/lib/relay/migrate.js +4 -4
- package/lib/relay/pairing-policy.js +8 -6
- package/lib/relay/port-allocator.cjs +2 -3
- package/lib/relay/preview.js +1 -1
- package/lib/relay/status.js +4 -4
- package/lib/relay/tunnel.js +21 -26
- package/lib/relay/verify.js +5 -3
- package/lib/routes/control-plane.js +1 -1
- package/lib/routes/mobile-api.js +20 -14
- package/lib/shared/device-headers.js +5 -1
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +3 -4
- package/lib/shared/e2ee.js +2 -1
- package/lib/shared/fresh-request.js +1 -2
- package/lib/shared/http-util.js +2 -1
- package/lib/shared/sse-write.js +1 -1
- package/lib/shell/manager.js +1 -1
- package/lib/shell/updater.js +2 -1
- package/lib/sidecar-facade-worker.cjs +1 -1
- package/lib/sidecar-facade.cjs +1 -1
- package/lib/sidecar-lifecycle.cjs +3 -3
- package/lib/update-registry.cjs +2 -2
- package/package.json +1 -1
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 「按新配置重建隧道失败」的台账:最近一次失败的错误、时间与当时要切过去的地址。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 了」,还得自己猜是不是地址填错了。台账只留最近一条,由
|
|
6
|
+
* 换地址失败若被吞掉,手机上看是「保存成功」然后永久离线,宿主日志里也一个字都没有 ——
|
|
7
|
+
* 用户唯一的线索是「连不上了」,还得自己猜是不是地址填错了。台账只留最近一条,由
|
|
9
8
|
* `/api/mobile/v1/relay/diagnostics` 原样报给手机(「运行状态」那一页读它)。
|
|
10
9
|
*
|
|
11
10
|
* 只留一条是刻意的:这里回答的是「刚才那次为什么没成」,不是故障统计(故障统计归
|
package/lib/relay/core.js
CHANGED
|
@@ -15,15 +15,15 @@ const sharedToken = require('../shared/dshtoken');
|
|
|
15
15
|
const paths = require('../shared/paths');
|
|
16
16
|
|
|
17
17
|
const STATE_FILE = paths.relayStateFile();
|
|
18
|
-
/**
|
|
18
|
+
/** 局域网代理签发的 `dsh_lan` Cookie 有效期(30 天),也是 `signLanToken` 的默认值。 */
|
|
19
19
|
const MOBILE_TOKEN_TTL_SEC = 30 * 24 * 3600;
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
21
|
+
* 全局 bearer token 的有效期(1 年)。
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* 当前配对签发的是每设备凭据(`token` / cookie / `deviceCredential` 三者同值),只有隧道
|
|
24
|
+
* `pairRequest` 那条兼容路径还会签这张不带设备身份的 token。它没有续签流程,只能靠宿主
|
|
25
|
+
* 轮换 `lanSecret`(`/api/relay/revoke-all`)整体作废;单设备吊销由 `dshc_` 设备凭据承担
|
|
26
|
+
* (见 `lib/mobile/auth.js`)。
|
|
27
27
|
*/
|
|
28
28
|
const MOBILE_BEARER_TTL_SEC = 365 * 24 * 3600;
|
|
29
29
|
|
|
@@ -68,7 +68,7 @@ function loadState() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
71
|
+
* 配对成功之后换掉配对码与票据。
|
|
72
72
|
*
|
|
73
73
|
* **两件必须同一个动作**:只换码不换票据,那张截图里的票据还能再配一次;只换票据不换码,
|
|
74
74
|
* 旧码在旧形状那条路上还能用。所以这里一次改两个字段、一次落盘。
|
|
@@ -101,10 +101,9 @@ function normalizeRelayUrl(relayUrl) {
|
|
|
101
101
|
/**
|
|
102
102
|
* 中转地址 → 隧道 WebSocket 地址。
|
|
103
103
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* `/api/relay/config` 能在保存时就给出明确反馈。
|
|
104
|
+
* 缺协议的输入(`example.com:8700`、`//relay.example`)会被拼成一个 `new WebSocket()`
|
|
105
|
+
* 直接拒绝的字符串,错误要等到隧道连接阶段才以 `tunnel.state='error'` 的形式浮现。
|
|
106
|
+
* 这里提前抛错,让 `/api/relay/config` 能在保存时就给出明确反馈。
|
|
108
107
|
*/
|
|
109
108
|
function tunnelWsUrl(relayUrl) {
|
|
110
109
|
const raw = normalizeRelayUrl(relayUrl);
|
|
@@ -154,9 +154,9 @@ function toCandidates(list, port, family = 'IPv4') {
|
|
|
154
154
|
}))
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// Structured view of the usable LAN endpoints. The
|
|
158
|
-
//
|
|
159
|
-
//
|
|
157
|
+
// Structured view of the usable LAN endpoints. The app's `candidateUrls()` accepts
|
|
158
|
+
// this shape, so callers that want interface metadata (for diagnostics or smarter
|
|
159
|
+
// ordering) should prefer it.
|
|
160
160
|
function lanInterfaces(port, interfaces = os.networkInterfaces()) {
|
|
161
161
|
return toCandidates(collectEndpoints(interfaces, isLanCandidateAddress), port)
|
|
162
162
|
}
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -37,9 +37,10 @@ const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
|
|
|
37
37
|
const WS_UPSTREAM_PENDING_MAX_BYTES = 32 * 1024 * 1024;
|
|
38
38
|
// 局域网直连允许同时活着的上游 SSE 条数。它兜的是**半开连接**:手机走了但 TCP 还没
|
|
39
39
|
// 被拆掉(飞机模式、睡死、拔线都不会发 FIN),只有这种情况才需要一道闸。
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// 与中转路的上限(tunnel.js 的 MAX_UPSTREAM_SSE_CONNECTIONS,同为 64)取同一个数:两条路
|
|
41
|
+
// 通向同一个宿主、同一种资源,一台手机的几个被丢弃连接不该把另一条路挡在 429 外面。
|
|
42
|
+
// 这边每台设备各有自己的 TCP,实测 3 台设备正常十来条,所以这个数只当失控闸——正常使用
|
|
43
|
+
// 不该碰到它。
|
|
43
44
|
const MAX_UPSTREAM_SSE_CONNECTIONS = 64;
|
|
44
45
|
|
|
45
46
|
function createLanProxy(opts) {
|
package/lib/relay/migrate.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* 换完地址那一刻,所有不在同一个局域网的设备就永远找不到它了 —— 「换服务器之后拿旧地址的
|
|
9
9
|
* 设备永远学不到新地址」就是缺这一帧。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 旧隧道**不立刻关**(成功、失败两条路都一样):新隧道注册期间它还在服务在途请求;失败时
|
|
12
|
+
* 更是只有它还能发撤销。注册完成后再留 `OLD_TUNNEL_LINGER_MS`,让「已受理」或「失败」那一发
|
|
13
|
+
* 响应沿着老路回去 —— 这一发的结果是用户填完地址之后唯一的反馈。
|
|
14
14
|
*
|
|
15
15
|
* 顺序只有这一份实现:worker 的 `configure()` 在检测到中转地址变了之后调 `switchRelay`,
|
|
16
16
|
* 手机端与控制面两个入口都走同一条下发路径,不各写一套。
|
|
@@ -53,7 +53,7 @@ function revoke(tunnel) {
|
|
|
53
53
|
* @param {() => Promise<unknown>} opts.open 建一条**新**隧道并返回它的注册 Promise;
|
|
54
54
|
* 不许先停旧的(旧的还要发撤销、还要把响应送回去)
|
|
55
55
|
* @param {(tunnel: object) => void} opts.drop 停掉一条隧道
|
|
56
|
-
* @returns {Promise<boolean>}
|
|
56
|
+
* @returns {Promise<boolean>} 登记发出且新隧道注册成功才为 true
|
|
57
57
|
*/
|
|
58
58
|
async function switchRelay({ oldTunnel, relayUrl, open, drop }) {
|
|
59
59
|
/** 留一段再收旧隧道:那一发「已受理 / 失败原因」的响应还在它上面往回走。 */
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 配对授权策略。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `/
|
|
8
|
-
*
|
|
6
|
+
* 无头宿主没有弹窗确认界面,授权改由本地策略决定。三个配对入口最终都落到 `evaluate()`:
|
|
7
|
+
* 移动端 `/pair` 直接调用;局域网代理的 onApprove 与隧道 pairRequest 经 worker 打到主进程的
|
|
8
|
+
* `/api/relay/internal/approval`。旁路只看 `approved` 是否为真,所以 'deny' 与恒定返回
|
|
9
|
+
* `{ approved: false }` 等价。
|
|
9
10
|
*
|
|
10
|
-
* 'code' —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* 'code' — 凭配对码 / 票据放行。`/pair` 与局域网代理在进入授权之前都已用恒定时间比较校验过
|
|
12
|
+
* 配对码(或解开票据),入口另有令牌桶限速(见 `shared/pair-throttle.js`);配对码
|
|
13
|
+
* 只在用户主动索取时展示,可用 `/api/relay/regenerate` 轮换、用
|
|
14
|
+
* `/api/relay/revoke-all` 连同 lanSecret 一并作废。
|
|
13
15
|
* 'deny' — 拒绝一切新配对。
|
|
14
16
|
*/
|
|
15
17
|
|
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* 局域网代理端口的自适应分配。
|
|
5
5
|
*
|
|
6
6
|
* 背景:默认端口 18787 是个「谁都能想到」的数字,于是同一台机器上只要还有别的
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `/api/relay/status` 只会说 `lan.state='error'`,用户看到的现象是「手机配不上对」,
|
|
7
|
+
* 服务先占了它,局域网代理的 `listen` 就会 EADDRINUSE,整条 LAN 直连链路直接死掉
|
|
8
|
+
* ——而 `/api/relay/status` 只会说 `lan.state='error'`,用户看到的现象是「手机配不上对」,
|
|
10
9
|
* 和真正的原因(端口被占)隔了十万八千里。
|
|
11
10
|
*
|
|
12
11
|
* 这里把「挑端口」从配置里解耦出来:先探测首选端口,空闲就用它;被占就让内核
|
package/lib/relay/preview.js
CHANGED
|
@@ -48,7 +48,7 @@ const MAX_PREVIEW_PENDING_BYTES = 1024 * 1024;
|
|
|
48
48
|
/**
|
|
49
49
|
* 从升级请求的查询串里取 port。
|
|
50
50
|
*
|
|
51
|
-
*
|
|
51
|
+
* 中转的升级分发(Rust relay 的 upgrade 入口)不看路径,只做鉴权后把
|
|
52
52
|
* `req.url` 原样交给隧道;宿主的 webServer 又只按 pathname 匹配升级路由、忽略查询串
|
|
53
53
|
* ——所以端口只能这么带,也只能这么读。
|
|
54
54
|
*
|
package/lib/relay/status.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 这份 JSON 有三个消费方,形状必须一模一样:桌面设置页、手机首屏都轮询
|
|
7
7
|
* `/api/mobile/v1/status`,二维码里嵌的也是它的子集。抽出来之后可以直接 require 测试,
|
|
8
|
-
*
|
|
8
|
+
* `impl.js` 只剩装配。
|
|
9
9
|
*
|
|
10
10
|
* `TUNNEL_ADMISSION` 靠正则匹配中转返回的错误文案来判断隧道为什么被拒。这是个脆弱契约
|
|
11
11
|
* (中转改一个字就漏判),但错误文案是当前唯一穿过隧道边界回来的信号,判定放宽到只认关键词。
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const os = require('node:os')
|
|
15
15
|
|
|
16
|
-
// 版本号只有一个来源:包自己的 package.json
|
|
16
|
+
// 版本号只有一个来源:包自己的 package.json。
|
|
17
17
|
const { version: pluginVersion } = require('../../package.json')
|
|
18
18
|
|
|
19
19
|
// 客户端半边(桌面页面里的 lib/client.js)的活体台账,只读它的计数快照。
|
|
@@ -21,12 +21,12 @@ const desktopHalf = require('../desktop-half-stats.cjs')
|
|
|
21
21
|
|
|
22
22
|
const TUNNEL_ADMISSION = [
|
|
23
23
|
{
|
|
24
|
-
// 中转关连接时用的文案是 'bad registration secret'
|
|
24
|
+
// 中转关连接时用的文案是 'bad registration secret'(中转侧拼的),
|
|
25
25
|
// 而 lib/tunnel-registration.js 内部的 reason 拼作 'bad register secret'。
|
|
26
26
|
// 两种拼法都出现过,所以正则认词干不认具体字母:regist + 任意尾巴 + secret。
|
|
27
27
|
pattern: /bad regist\w*\s+secret/i,
|
|
28
28
|
flag: 'registerSecretRequired',
|
|
29
|
-
//
|
|
29
|
+
// 这两条拒绝都由中转的准入判定给出(对应 lib/tunnel-registration.js 的口径),
|
|
30
30
|
// 文案变了这里就会漏判,所以判定正则放宽到只认关键词。
|
|
31
31
|
//
|
|
32
32
|
// 插件每 5 秒重试一次,而状态里只有一句「隧道关闭: 1008 bad registration secret」。
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 出站隧道客户端 v2:连接 dsh-mobile-server 中转,把中转发来的 HTTP/WS 请求转发到
|
|
5
5
|
* 本机 dsh web(127.0.0.1:<port>)。
|
|
6
6
|
*
|
|
7
|
-
* 协议 v2 相对 v1
|
|
7
|
+
* 协议 v2 相对 v1 的变更(协议定义见中转仓库 relay-core 的 `PROTOCOL.md`):
|
|
8
8
|
* - 二进制帧取代 JSON/base64 文本帧(数据通道不再 base64);
|
|
9
9
|
* - 手机请求 body 流式转发(httpRequest + httpBody*),不再整包缓冲;
|
|
10
10
|
* - 应用层 ping/pong 心跳(对 NAT/代理更可靠)。
|
|
@@ -18,10 +18,10 @@ const { encodeJsonBody } = require('../shared/http-util');
|
|
|
18
18
|
const { withViaHeader } = require('../shared/via-header');
|
|
19
19
|
|
|
20
20
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
const PONG_TIMEOUT_MS =
|
|
21
|
+
// 中转 60s 收不到 pong 就把这条隧道 terminate 掉(TUNNEL_PONG_TIMEOUT_MS)。宿主必须比它
|
|
22
|
+
// 先动或同时动:反过来就是「被踢的那一刻我们还以为连着」,那段时间手机发来的每个请求都要
|
|
23
|
+
// 白等一次响应头超时才见到失败。45s = 三拍 ping 都没回,迟到的 pong 与调度抖动还容得下。
|
|
24
|
+
const PONG_TIMEOUT_MS = 45_000;
|
|
25
25
|
const MAX_TUNNEL_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
26
26
|
const TUNNEL_BUFFER_LOW_WATER_BYTES = MAX_TUNNEL_BUFFERED_BYTES / 2;
|
|
27
27
|
const HTTP_RESPONSE_CHUNK_BYTES = 256 * 1024;
|
|
@@ -35,9 +35,8 @@ const UPSTREAM_RESPONSE_IDLE_TIMEOUT_MS = 30_000;
|
|
|
35
35
|
// Keep reconnect storms from monopolizing the local Host HTTP server.
|
|
36
36
|
//
|
|
37
37
|
// 64 与局域网那条直连闸(lan-proxy 的 MAX_UPSTREAM_SSE_CONNECTIONS)取同一个数:两条路
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// 使用设配额 —— 一台手机正常只占两条(会话流 + 设备级流)。
|
|
38
|
+
// 通向的是同一个宿主、同一种资源。上限的作用是挡住失控重连,不是给正常多设备使用设配额
|
|
39
|
+
// —— 一台手机正常只占两条(会话流 + 设备级流)。
|
|
41
40
|
const MAX_UPSTREAM_SSE_CONNECTIONS = 64;
|
|
42
41
|
class RelayTunnel {
|
|
43
42
|
/**
|
|
@@ -54,7 +53,7 @@ class RelayTunnel {
|
|
|
54
53
|
* relay.json(0600)。
|
|
55
54
|
* @param {() => void} [opts.onRegistered] 注册成功回调(用于状态展示)
|
|
56
55
|
* @param {(code: number, reason: string) => void} [opts.onClose] 连接断开回调(用于断线重连)
|
|
57
|
-
* @param {(msg: object) => void} [opts.onPairRequest]
|
|
56
|
+
* @param {(msg: object) => void} [opts.onPairRequest] 中转转发来的配对授权请求(宿主只按配对策略答复)
|
|
58
57
|
* @param {(msg: string) => void} [opts.onLog]
|
|
59
58
|
*/
|
|
60
59
|
constructor(opts) {
|
|
@@ -112,7 +111,7 @@ class RelayTunnel {
|
|
|
112
111
|
* 发送队列积压到上限时只丢这一帧,不断开隧道。
|
|
113
112
|
*
|
|
114
113
|
* 积压本身是「手机/中转读得慢」,不是隧道坏了;掐掉整条隧道会让所有设备一起掉线再等 5s
|
|
115
|
-
*
|
|
114
|
+
* 重连。中转侧同名上限同样只丢弃这一帧、不断开隧道,两侧口径必须一致:丢一帧可以恢复,
|
|
116
115
|
* 关一条隧道是全网断流。真正的「隧道已死」由心跳兜底:收不到 pong 才 terminate。
|
|
117
116
|
*/
|
|
118
117
|
dropOverBudget(bytes, frameLength) {
|
|
@@ -383,14 +382,10 @@ class RelayTunnel {
|
|
|
383
382
|
}
|
|
384
383
|
|
|
385
384
|
/**
|
|
386
|
-
*
|
|
385
|
+
* 更新本地配对码值。
|
|
387
386
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* 「中转看得见配对码」这个既成事实就不存在了,而这一轮之后配对码本身也不再是
|
|
391
|
-
* 新协议的凭证(票据才是)。
|
|
392
|
-
*
|
|
393
|
-
* 保留这个方法只因为 `updateRegistration` 那条路还在用它更新本地值。
|
|
387
|
+
* **不发往中转**:`registrationPayload()` 里没有这个字段,中转也不读它,它只留作本进程
|
|
388
|
+
* 内的值(面板显示)。
|
|
394
389
|
*/
|
|
395
390
|
updatePairingCode(code) {
|
|
396
391
|
this.pairingCode = code;
|
|
@@ -399,8 +394,8 @@ class RelayTunnel {
|
|
|
399
394
|
/**
|
|
400
395
|
* 注册帧的载荷。连接建立时发一次,之后元信息变了再由 updateRegistration 重发。
|
|
401
396
|
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
397
|
+
* **不带配对码**:中转不读它,带上它等于把「谁能配这台机器」的一半凭证交给链路上的
|
|
398
|
+
* 第三方。
|
|
404
399
|
*/
|
|
405
400
|
registrationPayload() {
|
|
406
401
|
return {
|
|
@@ -414,12 +409,12 @@ class RelayTunnel {
|
|
|
414
409
|
}
|
|
415
410
|
|
|
416
411
|
/**
|
|
417
|
-
*
|
|
412
|
+
* 在**现有连接上**重报一次注册信息(名字 / 局域网候选),不断连接。
|
|
418
413
|
*
|
|
419
414
|
* 中转那边同一个实例重复注册是幂等的(不会把自己顶掉),所以这条只是「更新名片」。
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
415
|
+
* 拆隧道重建的代价是手机所有在途请求一起断——真机现场就是「切个网络,接下来几秒点
|
|
416
|
+
* 什么都失败」。真正决定「连到哪台」的字段(实例、中转地址、注册密钥)不在这里更新,
|
|
417
|
+
* 它们变了必须重建,由 worker 的 configure 判定。
|
|
423
418
|
*/
|
|
424
419
|
updateRegistration(patch = {}) {
|
|
425
420
|
if (typeof patch.pairingCode === 'string' && patch.pairingCode) this.pairingCode = patch.pairingCode;
|
|
@@ -430,8 +425,8 @@ class RelayTunnel {
|
|
|
430
425
|
}
|
|
431
426
|
|
|
432
427
|
/**
|
|
433
|
-
*
|
|
434
|
-
* `hostPlatform` 是**宿主自己的** `process.platform
|
|
428
|
+
* 回传配对授权结果:approved + 宿主签发的移动 token(拒绝时无 token)。
|
|
429
|
+
* `hostPlatform` 是**宿主自己的** `process.platform`,随授权结果一起回给手机,
|
|
435
430
|
* 让配对那一刻就把「这台是什么系统」落盘(设备行不等 status 轮询)。
|
|
436
431
|
*/
|
|
437
432
|
sendPairConfirm(requestId, approved, token, hostPlatform) {
|
|
@@ -584,7 +579,7 @@ class RelayTunnel {
|
|
|
584
579
|
return;
|
|
585
580
|
}
|
|
586
581
|
if (isSse) this.sseClients.add(id);
|
|
587
|
-
//
|
|
582
|
+
// 读取隔离:命中本地快照时也走有界发送队列,避免快照响应在缓冲高水位时丢帧。
|
|
588
583
|
// 例外:带 ?fresh=1 的写后刷新必须回源(判定与实测见 lib/shared/fresh-request.js)。
|
|
589
584
|
if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
|
|
590
585
|
const body = this.readSnapshot({ method: msg.method, path: msg.path, headers: msg.headers });
|
package/lib/relay/verify.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 试连中转:拿**新地址**真注册一次,注册上了才算这个地址能用,随后立刻断开。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 手机端改中转地址走两阶段(`/api/mobile/v1/relay/config/save` 的 `stage:"prepare"` →
|
|
7
|
+
* `commit`),最坏的结果是这台电脑从网上消失 —— 而改地址的入口本身就挂在
|
|
8
|
+
* 那条隧道上。先落盘再重建等于让用户在一次手滑之后自己走回电脑前,所以落盘之前必须先
|
|
9
|
+
* 验证:这个动作就是那个验证。控制面 `/api/relay/config` 是用户在电脑上直接提交,
|
|
10
|
+
* 走的是「落盘 + 等下发」,不经过这里。
|
|
9
11
|
*
|
|
10
12
|
* **临时实例 id**:中转按实例 id 认人,拿正式 id 去试连会把正在服务的那条顶掉(真机上表现
|
|
11
13
|
* 为「点了一下验证,手机立刻掉线」)。所以用正式 id 加 `~probe~` 加随机段,连完马上断,
|
|
@@ -202,7 +202,7 @@ function panelUrlOf(req) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* @param {object} deps 见
|
|
205
|
+
* @param {object} deps 见 `lib/impl.js` 里 registerControlPlaneRoutes({...}) 的调用点
|
|
206
206
|
*/
|
|
207
207
|
function registerControlPlaneRoutes(deps) {
|
|
208
208
|
const {
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -49,6 +49,7 @@ const pairing = require('../mobile/pairing.js');
|
|
|
49
49
|
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
50
50
|
const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
|
|
51
51
|
const extensions = require('../mobile/extensions.js');
|
|
52
|
+
const sessionDelivery = require('../mobile/session-delivery.js');
|
|
52
53
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
53
54
|
const pkg = require('../../package.json');
|
|
54
55
|
const desktopView = require('../desktop-view.cjs');
|
|
@@ -107,8 +108,7 @@ function registerMobileRoutes(deps) {
|
|
|
107
108
|
// 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
|
|
108
109
|
// 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
|
|
109
110
|
// PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
|
|
110
|
-
//
|
|
111
|
-
// 「填的是宿主还是中转」。
|
|
111
|
+
// 中转侧在同一路径上回 'dsh-mobile-relay',App 靠这个字段分辨「填的是宿主还是中转」。
|
|
112
112
|
//
|
|
113
113
|
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
|
|
114
114
|
// 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
|
|
@@ -153,18 +153,25 @@ function registerMobileRoutes(deps) {
|
|
|
153
153
|
// 「哪台设备还能用」是宿主的判断。
|
|
154
154
|
//
|
|
155
155
|
// 旧实现回一句 `{ok:true}` 什么都不做:手机点了「删除设备」、本地删干净了,宿主那份
|
|
156
|
-
// 凭据还活着,那枚 token
|
|
157
|
-
//
|
|
156
|
+
// 凭据还活着,那枚 token 在到期之前仍然能用。现在按**出示的这枚凭据属于哪台**整台吊销。
|
|
157
|
+
//
|
|
158
|
+
// 为什么不按请求体里的 deviceId:手机手上只有自己的凭据,让它报 deviceId 等于允许吊销
|
|
159
|
+
// 别人;deviceId 只从库里那条记录取,报别人的号根本没有入口。为什么不只吊销出示的那一枚
|
|
160
|
+
// (2026-10-28 改):同一台手机重配、升级之后名下有好几条活凭据,旧的那条仍然能用,于是
|
|
161
|
+
// 「在手机上删设备」只把面板那一行的条数减一、行还在,用户以为删干净了其实没有。
|
|
158
162
|
route('/api/mobile/v1/device-credential/revoke', async (_ctx, req, res) => {
|
|
159
163
|
const presented = req.headers['x-dsh-device-credential']
|
|
160
164
|
? String(req.headers['x-dsh-device-credential'])
|
|
161
165
|
: (/^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '') || [])[1]
|
|
162
166
|
const store = credentialsEntry()
|
|
163
167
|
const instanceId = String(state.instanceId || '')
|
|
164
|
-
|
|
168
|
+
// 查不到记录(升级前那种纯 HMAC 通行证)就什么都不吊销:它没有记录、也就没有 deviceId,
|
|
169
|
+
// 那种只能靠控制面的「吊销全部」轮换 lanSecret 收掉。
|
|
170
|
+
const record = presented ? store.lookup(presented, instanceId) : null
|
|
171
|
+
const count = record ? store.revokeDevice(instanceId, record.deviceId) : 0
|
|
165
172
|
// 尽力而为的语义不变:手机那边失败也不阻断本地删除,所以这里始终回 ok,
|
|
166
|
-
//
|
|
167
|
-
sendJson(res, 200, { ok: true, revoked })
|
|
173
|
+
// 但把「到底吊销了几条」如实带上 —— 排查「删了还能用」时这一格是唯一线索。
|
|
174
|
+
sendJson(res, 200, { ok: true, revoked: count > 0, count })
|
|
168
175
|
})
|
|
169
176
|
|
|
170
177
|
// 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
|
|
@@ -219,13 +226,15 @@ function registerMobileRoutes(deps) {
|
|
|
219
226
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
220
227
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
221
228
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
222
|
-
//
|
|
229
|
+
// 会话文件:允许本会话 present 声明的路径及会话工作区内的文件(见 mobile/session-file.js)。
|
|
223
230
|
route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
// 交付(卡片、正文链接),放行面跟着一次交付走;这条跟着「我登记过的目录」走。
|
|
231
|
+
// 文件浏览器按绝对路径取字节,范围是宿主已登记的工作区及当前会话的项目目录
|
|
232
|
+
//(见 mobile/file-read.js 与 mobile/allowed-zone.js)。
|
|
227
233
|
route('/api/mobile/v1/file/read', fileRead.handleFileRead)
|
|
228
234
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
235
|
+
// 「这一发到底进没进」:App 的离线发件箱在重发之前先问一句(按 clientMessageId 回查
|
|
236
|
+
// 会话正文)。只读、只看最近一窗,见 mobile/session-delivery.js。
|
|
237
|
+
route('/api/mobile/v1/session/delivery', sessionDelivery.handleSessionDelivery)
|
|
229
238
|
// 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
|
|
230
239
|
// 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
|
|
231
240
|
// 回 known:false 让 App 退回全量,绝不现读一次历史——否则这探针就变成了它要省掉的那次请求。
|
|
@@ -280,9 +289,6 @@ function registerMobileRoutes(deps) {
|
|
|
280
289
|
route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
|
|
281
290
|
route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
|
|
282
291
|
route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
|
|
283
|
-
// App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
|
|
284
|
-
// (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
|
|
285
|
-
// 但插件从来没有注册 → 移动端「重命名工作区」和「归档会话」必然 404。
|
|
286
292
|
route('/api/mobile/v1/workspace/rename', workspaces.handleRenameWorkspace)
|
|
287
293
|
route('/api/mobile/v1/workspace/archive-session', workspaces.handleArchiveSession)
|
|
288
294
|
// 会话级开关的反向动作:取消归档 / 置顶 / 取消置顶。桌面端这三件事都有入口
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
* 认一台手机靠的是它自带的 `deviceId`(`X-Dsh-Device` 那条),不是这几格里的任何一样。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 手机自报身份用的头名(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。
|
|
29
|
+
*
|
|
30
|
+
* 新版 App 只发机型 / 平台 / 系统版本 / App 版本四个;名字那一格留给还带它的老版本。
|
|
31
|
+
*/
|
|
28
32
|
const DEVICE_NAME_HEADER = 'x-dsh-device-name';
|
|
29
33
|
const DEVICE_MODEL_HEADER = 'x-dsh-device-model';
|
|
30
34
|
const PLATFORM_HEADER = 'x-dsh-platform';
|
package/lib/shared/dshtoken.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 插件侧 LAN/mobile token(HMAC-SHA256 base64url)共享实现。
|
|
5
5
|
*
|
|
6
6
|
* token 格式:<instanceId>.<exp>.<hmac-sha256(secret, "<instanceId>.<exp>") base64url>
|
|
7
|
-
* 其中 exp 为秒级过期时间戳。signLanToken
|
|
8
|
-
*
|
|
7
|
+
* 其中 exp 为秒级过期时间戳。signLanToken 收 ttlSec/nowSec,verifyLanToken 收
|
|
8
|
+
* secret/token/instanceId;有效期由调用方按用途传入,本函数不隐藏调用点差异。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const crypto = require('node:crypto');
|
|
@@ -24,8 +24,8 @@ function hmacPayload(secret, payload) {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* 签发 LAN/mobile token:<instanceId>.<exp>.<sig>。
|
|
27
|
-
* 缺省 ttlSec=30d
|
|
28
|
-
*
|
|
27
|
+
* 缺省 ttlSec=30d;调用方按用途传值(`relay/core.js` 的 signLanToken 缺省 30 天,
|
|
28
|
+
* worker 签发 Bearer token 时传 365 天),本函数不隐藏调用点的默认值差异。
|
|
29
29
|
* @param {string} secret lanSecret(relay 配置里的密钥)
|
|
30
30
|
* @param {string} instanceId 实例 id(relay 配置)
|
|
31
31
|
* @param {number} ttlSec 有效期(秒),缺省 30*24*3600
|
|
@@ -281,11 +281,10 @@ function unwrapper(wire) {
|
|
|
281
281
|
/**
|
|
282
282
|
* 读原始请求体(信封要求「按线上原样」算摘要,所以不能先 JSON.parse)。
|
|
283
283
|
*
|
|
284
|
-
* 上限按 §8.3(9) 给的数:body 上限按 4/3
|
|
285
|
-
*
|
|
284
|
+
* 上限按 §8.3(9) 给的数:body 上限按 4/3 放大,判定仍按明文。最大的明文是上传片
|
|
285
|
+
* (11MB 那一档),信封再加 4/3;明文侧的判定在各自路由里(`readJsonBody(req, ...)`),
|
|
286
|
+
* 两边不共用同一个数。超了就当 413,别让一个畸形请求把内存吃光。
|
|
286
287
|
*/
|
|
287
|
-
// 线上那一侧的上限:最大的明文是上传片(11MB 那一档),信封再加 4/3。
|
|
288
|
-
// 明文侧的判定在各自路由里(`readJsonBody(req, ...)`),两边不共用同一个数。
|
|
289
288
|
const MAX_SEALED_BODY = Math.ceil(11 * 1024 * 1024 * 4 / 3);
|
|
290
289
|
|
|
291
290
|
function readRawBody(req, maxBytes = MAX_SEALED_BODY) {
|
package/lib/shared/e2ee.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 链路加密(协议 v2)的字节级原语。App
|
|
4
|
+
* 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现
|
|
5
|
+
* (`app/src/services/viewmodels/e2ee.ts`),两边的
|
|
5
6
|
* 正确性靠同一份测试向量保证(`test/e2ee-vectors.json`),不靠互相阅读代码。
|
|
6
7
|
*
|
|
7
8
|
* 这一层只做「算字节」,不认识路由与业务:信封放在哪里、什么时候加解密由路由层负责。
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// 会话列表缓存。轮询撞上它们无所谓,写后第一次刷新撞上就会把刚做完的事显示成没生效。
|
|
7
7
|
//
|
|
8
8
|
// 所以约定 `?fresh=1`:带这个标记的读跳过所有代读副本、直接回源;轮询 / 首屏 / 下拉刷新不带。
|
|
9
|
-
//
|
|
10
|
-
// 中转也必须认这个标记,否则 App 就失去了唯一的自救手段。
|
|
9
|
+
// 权威实现在本文件;中转(Rust relay)也认这个标记,否则 App 就失去了唯一的自救手段。
|
|
11
10
|
|
|
12
11
|
const FRESH_QUERY_RE = /(?:^|[?&])fresh=1(?:&|$)/
|
|
13
12
|
|
package/lib/shared/http-util.js
CHANGED
|
@@ -69,7 +69,8 @@ function sendJson(res, status, body) {
|
|
|
69
69
|
// **协议 v2 的响应不在这里压**:守卫是在 `res.end` 上把 JSON 解出来再封成信封的,
|
|
70
70
|
// 先压成 gzip 的话那一层只能看到二进制,`JSON.parse` 失败就退化成「原样写出、没封」——
|
|
71
71
|
// 结果是最大的那批响应(列表 / 消息 / 统计)反而不加密。压缩改由信封写出去之前做,
|
|
72
|
-
// 那一层拿到的仍是 JSON 文本(见 `lib/http/routing.js` 的 `res.end`
|
|
72
|
+
// 那一层拿到的仍是 JSON 文本(见 `lib/http/routing.js` 的 `res.end` 钩子与
|
|
73
|
+
// `shared/e2ee-request.js` 的 `sealResponse`)。
|
|
73
74
|
const sealed = !!(res && res.req && res.req.__dshE2ee);
|
|
74
75
|
const encoded = encodeJsonBody(text == null ? '' : text, sealed ? undefined : acceptEncoding);
|
|
75
76
|
if (encoded.gzip) headers['content-encoding'] = 'gzip';
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 控制帧没有 seq 但绝不能丢。设备级流的帧全是幂等信号,丢掉后由调用方断流重连并补发。
|
|
14
14
|
* - 写不动时:会话流 await 一次 drain(20s 超时收尾);设备级流直接收尾让客户端重连,重连
|
|
15
15
|
* 时会补发当前状态,所以断开是它的正常收敛路径。
|
|
16
|
-
* -
|
|
16
|
+
* - 填充:会话流按 2048 字节的窗口补注释(Android RN 的 XHR 会攒小 chunk,不补迟迟不触发
|
|
17
17
|
* onprogress);设备级流帧少且小,不补。
|
|
18
18
|
*/
|
|
19
19
|
|
package/lib/shell/manager.js
CHANGED
|
@@ -205,7 +205,7 @@ function evictionRoots(generation) {
|
|
|
205
205
|
* 于是「同一路径下的代码被换掉了」时,新入口会拿到旧子模块 —— 实测:改盘上的子模块、
|
|
206
206
|
* 再 `import(entry?v=…)`,拿到的仍是旧值;先清缓存才拿到新值。
|
|
207
207
|
*
|
|
208
|
-
* 这个坑只在**同一个路径被第二次加载**时出现。正常 OTA
|
|
208
|
+
* 这个坑只在**同一个路径被第二次加载**时出现。正常 OTA 落到 `<profile>/.dsh-mobile-ota/<id>/`,
|
|
209
209
|
* 路径是新的,踩不到;但基础代(包内 `lib/impl.js`)路径固定 —— 只要 profile 里的插件包
|
|
210
210
|
* 在运行期间被 pnpm 换过版本,换代回基础代就会拿到上一版实现:新路由没注册、
|
|
211
211
|
* `appVersion` 报旧版本号,而状态里却写着新版本。
|
package/lib/shell/updater.js
CHANGED
|
@@ -208,7 +208,8 @@ export async function operation(base, operationId) {
|
|
|
208
208
|
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
*
|
|
211
|
+
* 装新版:盘上那一代带自装模块(`update-install.cjs`)就在 profile 里自己装,否则退回市场
|
|
212
|
+
* 那条路 —— 问市场、请它装、盯到终态。
|
|
212
213
|
*
|
|
213
214
|
* **不重启 DSH**:市场报的 `restartRequired` 只往上传,装完由外壳换代解决(见 index.js)。
|
|
214
215
|
* 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 宿主不自己开 WebSocket、也不自己监听局域网端口,而是 fork 出这个 worker,用环回 HTTP +
|
|
7
7
|
* Bearer 令牌对话。隧道崩了、原生模块加载失败都只死一个子进程,宿主仍能如实回报状态。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 四条不能忘的语义:
|
|
10
10
|
* 1. 状态既要在 `/status` 上可查,也要在变化那一刻主动 `process.send` 给宿主:健康轮询是
|
|
11
11
|
* 10s 一次,只靠轮询的话隧道真断后宿主最多 10s 仍以为连着;「断又连」落在两次轮询之间时
|
|
12
12
|
* 那次全量补推也会漏掉。
|
package/lib/sidecar-facade.cjs
CHANGED
|
@@ -91,7 +91,7 @@ class SidecarFacade {
|
|
|
91
91
|
await this.request('/health')
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// `timeoutMs` 可覆盖:隧道侧的 `/revoke-all`
|
|
94
|
+
// `timeoutMs` 可覆盖:隧道侧的 `/revoke-all` 要等中转逐个断开设备(调用方给 20s),
|
|
95
95
|
// 而这里旧实现硬编码 5s,慢中转下宿主永远先超时、`/api/relay/revoke-all` 只好
|
|
96
96
|
// 报 revoked:0(看起来像「撤销成功但一台都没撤」)。
|
|
97
97
|
request(pathname, method = 'GET', body, timeoutMs = 5000) {
|
|
@@ -27,11 +27,11 @@ const HEALTH_POLL_INTERVAL_MS = 10000;
|
|
|
27
27
|
* @param {object} deps
|
|
28
28
|
* @param {import('cordis').Context} deps.ctx
|
|
29
29
|
* @param {string} [deps.pluginLibDir] 本代实现所在的 `lib/` 目录。默认按本文件位置推;
|
|
30
|
-
*
|
|
31
|
-
* `require.resolve`
|
|
30
|
+
* 之所以要能注入,是因为实现可能装在 profile 下的版本目录里(或 profile OTA 回退的那份),
|
|
31
|
+
* `require.resolve` 会解析到别处的同名声骸,而调用方手上的目录才是最准的。
|
|
32
32
|
* @param {() => string} deps.lanPortInUse 取值器:lanPortAdjusted/effectiveLanPort 会被重新赋值
|
|
33
33
|
* @param {() => Promise<number>} deps.resolveLanPort
|
|
34
|
-
* @param {(cfg: object) => Promise<object>} deps.buildConfig
|
|
34
|
+
* @param {(cfg: object) => Promise<object>} deps.buildConfig 把它组装的配置交给谁下发(`lib/impl.js` 组装)
|
|
35
35
|
*/
|
|
36
36
|
function createSidecarLifecycle(deps) {
|
|
37
37
|
const { ctx, lanPortInUse, resolveLanPort, buildConfig } = deps;
|
package/lib/update-registry.cjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* profile 里自己跑包管理器。绕开市场的理由很具体:市场没装、太老或坏了,「检查更新」就整个
|
|
8
8
|
* 答不出来。
|
|
9
9
|
*
|
|
10
|
-
* 边界:这里只查不装,不碰 profile、不写盘;`source
|
|
11
|
-
*
|
|
10
|
+
* 边界:这里只查不装,不碰 profile、不写盘;`source` 由调用方给(盘上依赖声明或上次问到的
|
|
11
|
+
* 市场结果),本模块只透传,不会为它去敲市场。判据是「线上版本严格大于盘上版本」,线上
|
|
12
12
|
* 更旧一律报没有新版——「这次更新会降级」必须由版本比较判定。
|
|
13
13
|
*/
|
|
14
14
|
|
package/package.json
CHANGED