dsh-mobile-plugin 0.4.5 → 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 +2 -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 +1 -1
- package/lib/mobile/host-bridge/shims.js +2 -2
- package/lib/mobile/inbox-clean.js +3 -2
- 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/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 +16 -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
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
document.head.appendChild(tag);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* ──
|
|
53
|
+
/* ── 一:桌面工作区展开态同步 ── */
|
|
54
54
|
/** 桌面端持久化键:`dsh.workspace.view.v5`(版本号可能上浮,取版本最大的那个)。 */
|
|
55
55
|
const STORAGE_KEY_RE = /^dsh\.workspace\.view\.v(\d+)$/;
|
|
56
56
|
/** 轮询间隔:展开/折叠是低频操作,2s 足够跟手又不费电。 */
|
|
@@ -501,7 +501,8 @@ window.__ModuleLoader__.load({
|
|
|
501
501
|
* 保持一个薄卡片:一个网页版入口,以及一个可信本机开关,控制网页管理是否短时向公网开放。
|
|
502
502
|
* 界面本体仍在宿主吐出的 HTML(源码 `React/`,产物 `lib/panel/index.html`)。
|
|
503
503
|
*
|
|
504
|
-
* 本机设置项半边来自渲染器的 `node_modules
|
|
504
|
+
* 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件本体由外壳自己装新版
|
|
505
|
+
* (`lib/update-install.cjs`),刷新页面才看得到这一半的变化。
|
|
505
506
|
* 开关只影响控制面网页路由,不改移动 API、配对凭据或隧道状态。
|
|
506
507
|
*/
|
|
507
508
|
function MobileSettingsSection({ t }) {
|
package/lib/git/exec.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 边界仍然成立;
|
|
10
10
|
* 2. **失败不当异常抛**。非零退出是常态(没有上游、坏分支名、钩子拒绝),调用方要拿退出码
|
|
11
11
|
* 和 stderr 判断,所以一律 resolve 成 `{ code, stdout, stderr }`,只有「git 根本没装」
|
|
12
|
-
*
|
|
12
|
+
* 「超时被杀」「输出超限」单独立成 `error` 字段;
|
|
13
13
|
* 3. **按仓库串行**。手机上一次点两下就会并发两次写,撞上 `.git/index.lock` 会互相踩死;
|
|
14
14
|
* 锁的粒度是仓库根,主仓库与它下面的独立子仓库各排各的队。
|
|
15
15
|
*
|
package/lib/host-guard.cjs
CHANGED
|
@@ -40,7 +40,7 @@ function describeError(err) {
|
|
|
40
40
|
* @param {(line: string) => void} [options.log] 注入日志,便于测试
|
|
41
41
|
* @param {(code: number) => void} [options.exit] 注入退出,便于测试
|
|
42
42
|
* @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
|
|
43
|
-
*
|
|
43
|
+
* recordFault: Function, faults: Function, stats: Function, clear: Function,
|
|
44
44
|
* isFatal: Function}}
|
|
45
45
|
*/
|
|
46
46
|
function createGuard(options = {}) {
|
package/lib/host-patches.cjs
CHANGED
package/lib/impl.js
CHANGED
|
@@ -91,8 +91,8 @@ export const RemoteControlSettingsSchema = z.object({
|
|
|
91
91
|
lanPort: z.number().step(1).min(1024).max(65535).default(18787),
|
|
92
92
|
name: z.string().default(''),
|
|
93
93
|
// Pairing approval policy; see lib/relay/pairing-policy.js for the two modes and
|
|
94
|
-
// why 'code' is not the old popup. Kept in the
|
|
95
|
-
//
|
|
94
|
+
// why 'code' is not the old popup. Kept in the config schema so /api/relay/config
|
|
95
|
+
// can change it, and so an unknown value is rejected on write.
|
|
96
96
|
pairingPolicy: z.union(pairingApproval.POLICIES.map(value => z.const(value))).default(pairingApproval.DEFAULT_POLICY),
|
|
97
97
|
// 中转注册密钥(对偶于中转的 TUNNEL_REGISTER_SECRET)。桌面版拿不到 shell
|
|
98
98
|
// 环境变量,所以它和其他配置一样存在 relay.json(0600),由 /api/relay/config
|
|
@@ -465,11 +465,6 @@ function apply(ctx) {
|
|
|
465
465
|
hostMetadata,
|
|
466
466
|
// 取值器而非快照对象:snapshot 会被 applySidecarConfig 整体替换,闭包捕获对象会让
|
|
467
467
|
// 连接信息永远停在进程启动那一刻。
|
|
468
|
-
//
|
|
469
|
-
// 曾经这里写的是 `() => sidecarSnapshot` —— 那个标识符在本文件作用域里根本不存在
|
|
470
|
-
// (它只在 lib/sidecar-lifecycle.cjs 内部),于是 GET /api/mobile/v1/connection-info
|
|
471
|
-
// 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
|
|
472
|
-
// `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
|
|
473
468
|
getSidecarSnapshot: sidecarLifecycle.getSnapshot,
|
|
474
469
|
// 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
|
|
475
470
|
safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
|
package/lib/index.js
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ## 更新来源
|
|
19
19
|
*
|
|
20
|
-
* 正式 npm 更新由 `shell/updater.js`
|
|
20
|
+
* 正式 npm 更新由 `shell/updater.js` 处理(自动更新默认开启,面板可关);紧急完整包由
|
|
21
21
|
* `shell/device-ota.js` 经本机控制面激活,正式版成功接管前保留 OTA 回退包。
|
|
22
|
-
* `updateState`
|
|
22
|
+
* `updateState` 记录查装结果;对外的 `marketResult` 就是它的 `result`。
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import path from 'node:path'
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
|
|
5
5
|
*
|
|
6
6
|
* 三条不能丢的约束:隧道没连上就不构建快照(否则 503 刷屏,真故障被淹没);空清单/空快照
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 必须过守卫(没有 `emptyConfirmed` 的「没取到」不许推,否则手机侧整行消失且零报错);
|
|
8
|
+
* 镜像先推上去,再广播清单 revision(`announceSessionsRevision`),顺序反了手机会来取上一份镜像。
|
|
9
9
|
*
|
|
10
10
|
* 必须是 .cjs:包根声明 type=module,而 lib/mirror/ 没有自己的 `{"type":"commonjs"}`。
|
|
11
11
|
*/
|
package/lib/mobile/events.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEDUP_WINDOW = 2048;
|
|
|
23
23
|
const HEARTBEAT_MS = 15_000;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成
|
|
26
|
+
* 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 3 分钟,宿主日志里却查不到这条流是
|
|
27
27
|
* 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
|
|
28
28
|
*/
|
|
29
29
|
function logDeviceStream(message, error) {
|
|
@@ -203,7 +203,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
203
203
|
* 连上就先补一帧「当前状态」。
|
|
204
204
|
*
|
|
205
205
|
* 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
|
|
206
|
-
* 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到
|
|
206
|
+
* 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 3 分钟。
|
|
207
207
|
*
|
|
208
208
|
* 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
|
|
209
209
|
* App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
|
|
@@ -231,7 +231,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
231
231
|
const keepAlive = setInterval(() => {
|
|
232
232
|
try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
|
|
233
233
|
// 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
|
|
234
|
-
// 兜底轮询降到
|
|
234
|
+
// 兜底轮询降到 3 分钟(见 app/src/services/viewmodels/signal-liveness.ts)。
|
|
235
235
|
if (!writeFrame({ type: 'device/heartbeat', at: Date.now() })) ac.abort();
|
|
236
236
|
}, HEARTBEAT_MS);
|
|
237
237
|
// 定时器不该拖住进程退出(同 lib/sidecar-facade-worker.cjs 的 shutdown)。测试里的
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 旧 mux 一次给出「这个设备正在发生的一切」,0.2.0 没有对应端点(事件已拆成
|
|
7
7
|
* session/follow、session/control、workspace/follow),这里按 payload 里有没有 sessionId
|
|
8
8
|
* 合成同形帧:带 sessionId → 该会话的 follow + job 整帧 + 投影整帧;不带 → 设备级的
|
|
9
|
-
* 工作区基线 + 全局投影整帧。调用方(stream.js / events.js /
|
|
9
|
+
* 工作区基线 + 全局投影整帧。调用方(stream.js / events.js / lib/mirror/pusher.cjs)本来就
|
|
10
10
|
* 按 frame.payload 里的 type 分发,形状对上了它们一行都不用改。
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -134,7 +134,7 @@ async function workspaceListViaFollow(ctx, signal) {
|
|
|
134
134
|
}
|
|
135
135
|
// 流「正常结束却没吐 baseline」不是「你没有工作区」,而是没取到。原先这里返回
|
|
136
136
|
// ok({ items: [] }),与真成功在下游完全无法区分:宿主正在初始化 / 正忙 / 网关给了
|
|
137
|
-
// 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/
|
|
137
|
+
// 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/mirror/pusher.cjs 的
|
|
138
138
|
// 镜像守卫、被当作权威清单推给中转,手机侧表现为工作区整行消失、会话全塌进
|
|
139
139
|
// 「未分组」,且中转只会附一个 App 根本不看的 stale 标记,不会有任何报错。
|
|
140
140
|
// 拿不到就是拿不到,如实走错误通道,让调用方去重试或保留上一次结果。
|
|
@@ -159,7 +159,7 @@ function projectionValues(item) {
|
|
|
159
159
|
/**
|
|
160
160
|
* 子代理目录的投影基线预算,比通用合成流(SYNTHETIC_DEADLINE_MS)更紧。
|
|
161
161
|
*
|
|
162
|
-
* 手机端一次请求的总预算只有 8 秒(app/src/api.ts 的 REQUEST_TIMEOUT_MS),走中转还要
|
|
162
|
+
* 手机端一次请求的总预算只有 8 秒(app/src/api/core.ts 的 REQUEST_TIMEOUT_MS),走中转还要
|
|
163
163
|
* 多一个 RTT。基线开不出来并不致命 —— session.list 摘要内联的是同一份 values,退回它
|
|
164
164
|
* 就行,所以宁可慢的时候少等几秒,也别把整次 subagents.list 拖成客户端超时。
|
|
165
165
|
*/
|
|
@@ -36,8 +36,9 @@ function autoClearFile() {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* 读自动清理开关。读不到 / 读坏了都当「关」——
|
|
40
|
-
*
|
|
39
|
+
* 读自动清理开关。读不到 / 读坏了都当「关」—— 无人值守删盘这种事,缺省必须在用户这一侧。
|
|
40
|
+
* 自动更新那个开关刻意相反(文件不在就当作开,见 `lib/shell/updater.js` 的
|
|
41
|
+
* `autoUpdateEnabled`):装新版可回退,删文件不可回退,两个缺省因此不该相同。
|
|
41
42
|
*/
|
|
42
43
|
function autoClearEnabled() {
|
|
43
44
|
try {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* 的 127.0.0.1 上,手机既直连不到、也没有 `<a download>`,所以这里由插件代取并
|
|
9
9
|
* 原样转发宿主 `apiProxy.downloads.sessionLog` 的 application/zip 流。
|
|
10
10
|
*
|
|
11
|
+
* 0.2.x 的兼容桥没有这条端点(host-bridge.js 的 `downloads.sessionLog` 直接回
|
|
12
|
+
* `host-unsupported`),所以这一代宿主上本路由只能如实回 502;等宿主把导出能力重新
|
|
13
|
+
* 暴露出来,上面这段转发才真的走得到。
|
|
14
|
+
*
|
|
11
15
|
* 与桌面客户端保持同一口径:`includeDescendants` 缺省即 true —— 导出的是「当前会话
|
|
12
16
|
* + 子会话 + 附件」的整棵会话树,而不是单个会话。
|
|
13
17
|
*/
|
|
@@ -80,7 +80,7 @@ async function buildStatsSnapshot(ctx, sessionId) {
|
|
|
80
80
|
* 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
|
|
81
81
|
* 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
|
|
82
82
|
* `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
|
|
83
|
-
* 历史轮询是每
|
|
83
|
+
* 历史轮询是每 15 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
|
|
84
84
|
*/
|
|
85
85
|
async function statsProjections(ctx, sessionId) {
|
|
86
86
|
// 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
|
package/lib/mobile/stream.js
CHANGED
|
@@ -538,6 +538,8 @@ async function handleStream(ctx, req, res) {
|
|
|
538
538
|
// 先吸工具名再减:结果自己不带工具名,正文留不留取决于它(见 wire-cut.js)。
|
|
539
539
|
toolNames.absorb(event);
|
|
540
540
|
event = cutEvent(event, toolNames);
|
|
541
|
+
// 整条不上线路的事件(`tool/call`、参数被砍空之后的工具增量帧):见 wire-cut.js。
|
|
542
|
+
if (!event) continue;
|
|
541
543
|
const chunk = event.data && event.data.chunk;
|
|
542
544
|
if (seq !== null || !chunk || typeof chunk.type !== 'string') {
|
|
543
545
|
// 持久事件(或不是增量的会话事件):先把攒着的草稿写掉,再按原样写这一帧。
|
|
@@ -651,6 +653,8 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
651
653
|
const e = entry?.event || entry;
|
|
652
654
|
const resolved = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
|
|
653
655
|
const event = cutEvent(resolved, toolNames);
|
|
656
|
+
// 回放与直播同一副眼镜:整条不上线路的事件这里也跳过(见 wire-cut.js)。
|
|
657
|
+
if (!event) continue;
|
|
654
658
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
655
659
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
656
660
|
const payload = { type: 'session/event', sessionId, event };
|
package/lib/mobile/wire-cut.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 料 —— 计划卡读 `exit_plan_mode` 的 `plan`、扩展卡读 `extension_present` 的 `extension`、
|
|
11
11
|
* 定时任务卡解析 `schedule_create` 结果的 JSON。
|
|
12
12
|
*
|
|
13
|
+
* 除了「内容」还要收「帧数」:手机只回答「在干什么」,所以参数被砍空之后的工具增量帧
|
|
14
|
+
* (一次调用几十帧、每窗补 2KB)与没人消费的 `tool/call` 事件整条丢掉 —— 一个工具调用
|
|
15
|
+
* 在线上只剩**一帧身份**(`id` + `name`)加一帧结果。
|
|
16
|
+
*
|
|
13
17
|
* 直播帧循环(stream.js)与历史派生(history-derive.js)调的是同一份实现。同一条规则写两份,
|
|
14
18
|
* 迟早漂成「直播里还带着推理、拉一次历史又没有了」。
|
|
15
19
|
*/
|
|
@@ -111,29 +115,34 @@ function cutAssistantEvent(event) {
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
|
-
* 一条 `tool/call`
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
+
* 一条 `tool/call` 事件:**整条不下发**。
|
|
119
|
+
*
|
|
120
|
+
* 手机端对这条事件没有任何消费者:调用名由工具块的身份帧带到,状态与图片由 `tool/result`
|
|
121
|
+
* 回填,三张轮尾卡的料取自 `assistant/message` 里的 tool-call 块(见 App 的 `turn-tail-cards`)。
|
|
122
|
+
* 而它带 seq,属于「其余帧补满整份」那一类 —— 一帧就是 2KB 注释,一个长任务几十次调用、
|
|
123
|
+
* 两次报路(宿主→中转→手机)都是白走的。
|
|
118
124
|
*/
|
|
119
|
-
function cutToolCallEvent(
|
|
120
|
-
|
|
121
|
-
if (!data || typeof data !== 'object') return event;
|
|
122
|
-
if (typeof data.name === 'string' && ARGUMENT_KEEP_TOOLS.has(data.name)) return event;
|
|
123
|
-
if (data.arguments === undefined) return event;
|
|
124
|
-
const { arguments: _droppedArguments, ...rest } = data;
|
|
125
|
-
return { ...event, data: rest };
|
|
125
|
+
function cutToolCallEvent() {
|
|
126
|
+
return null;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
/**
|
|
129
|
-
* 一块流式增量:`tool-call-delta`
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* 一块流式增量:`tool-call-delta` 只留**身份帧**,其余整帧丢掉。
|
|
131
|
+
*
|
|
132
|
+
* 参数正文不下发(`write` / `edit` 的参数就是整份文件内容,按 token 一帧地推),于是同一块
|
|
133
|
+
* 后面那些帧只剩 `{ type, index }` —— 而 `index` 对手机没有用处,它要的是「有个工具开始了」
|
|
134
|
+
* 和那个工具叫什么。这些空壳在线上按 200ms 一窗合并、每窗补到 2KB(见 `stream.js`),
|
|
135
|
+
* 一个改文件的调用光参数流式那十几秒就能白走几十上百 KB。
|
|
136
|
+
*
|
|
137
|
+
* 判据只看这一帧自己带不带身份(`id` / `name`),不记状态:宿主把名字放在第一帧,也有的
|
|
138
|
+
* 宿主分开给,两种都留得住。
|
|
132
139
|
*/
|
|
133
140
|
function cutAssistantChunkEvent(event) {
|
|
134
141
|
const data = event && event.data;
|
|
135
142
|
const chunk = data && data.chunk;
|
|
136
143
|
if (!chunk || typeof chunk !== 'object' || chunk.type !== 'tool-call-delta') return event;
|
|
144
|
+
const identifies = chunk.id !== undefined || typeof chunk.name === 'string';
|
|
145
|
+
if (!identifies) return null;
|
|
137
146
|
if (chunk.argumentsDelta === undefined) return event;
|
|
138
147
|
const { argumentsDelta: _droppedArguments, ...rest } = chunk;
|
|
139
148
|
return { ...event, data: { ...data, chunk: rest } };
|
|
@@ -141,7 +150,7 @@ function cutAssistantChunkEvent(event) {
|
|
|
141
150
|
|
|
142
151
|
/**
|
|
143
152
|
* 事件类型 → 这条事件该怎么减:能减的类型走对应的一条规则,其余原样透传。
|
|
144
|
-
*
|
|
153
|
+
* **返回 null = 这条事件整条不上线路**,调用方必须跳过它(见 `stream.js` 的两处)。
|
|
145
154
|
*/
|
|
146
155
|
function cutEvent(event, names) {
|
|
147
156
|
if (!event || typeof event !== 'object') return event;
|
|
@@ -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
|
@@ -108,8 +108,7 @@ function registerMobileRoutes(deps) {
|
|
|
108
108
|
// 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
|
|
109
109
|
// 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
|
|
110
110
|
// PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
|
|
111
|
-
//
|
|
112
|
-
// 「填的是宿主还是中转」。
|
|
111
|
+
// 中转侧在同一路径上回 'dsh-mobile-relay',App 靠这个字段分辨「填的是宿主还是中转」。
|
|
113
112
|
//
|
|
114
113
|
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
|
|
115
114
|
// 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
|
|
@@ -154,18 +153,25 @@ function registerMobileRoutes(deps) {
|
|
|
154
153
|
// 「哪台设备还能用」是宿主的判断。
|
|
155
154
|
//
|
|
156
155
|
// 旧实现回一句 `{ok:true}` 什么都不做:手机点了「删除设备」、本地删干净了,宿主那份
|
|
157
|
-
// 凭据还活着,那枚 token
|
|
158
|
-
//
|
|
156
|
+
// 凭据还活着,那枚 token 在到期之前仍然能用。现在按**出示的这枚凭据属于哪台**整台吊销。
|
|
157
|
+
//
|
|
158
|
+
// 为什么不按请求体里的 deviceId:手机手上只有自己的凭据,让它报 deviceId 等于允许吊销
|
|
159
|
+
// 别人;deviceId 只从库里那条记录取,报别人的号根本没有入口。为什么不只吊销出示的那一枚
|
|
160
|
+
// (2026-10-28 改):同一台手机重配、升级之后名下有好几条活凭据,旧的那条仍然能用,于是
|
|
161
|
+
// 「在手机上删设备」只把面板那一行的条数减一、行还在,用户以为删干净了其实没有。
|
|
159
162
|
route('/api/mobile/v1/device-credential/revoke', async (_ctx, req, res) => {
|
|
160
163
|
const presented = req.headers['x-dsh-device-credential']
|
|
161
164
|
? String(req.headers['x-dsh-device-credential'])
|
|
162
165
|
: (/^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '') || [])[1]
|
|
163
166
|
const store = credentialsEntry()
|
|
164
167
|
const instanceId = String(state.instanceId || '')
|
|
165
|
-
|
|
168
|
+
// 查不到记录(升级前那种纯 HMAC 通行证)就什么都不吊销:它没有记录、也就没有 deviceId,
|
|
169
|
+
// 那种只能靠控制面的「吊销全部」轮换 lanSecret 收掉。
|
|
170
|
+
const record = presented ? store.lookup(presented, instanceId) : null
|
|
171
|
+
const count = record ? store.revokeDevice(instanceId, record.deviceId) : 0
|
|
166
172
|
// 尽力而为的语义不变:手机那边失败也不阻断本地删除,所以这里始终回 ok,
|
|
167
|
-
//
|
|
168
|
-
sendJson(res, 200, { ok: true, revoked })
|
|
173
|
+
// 但把「到底吊销了几条」如实带上 —— 排查「删了还能用」时这一格是唯一线索。
|
|
174
|
+
sendJson(res, 200, { ok: true, revoked: count > 0, count })
|
|
169
175
|
})
|
|
170
176
|
|
|
171
177
|
// 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
|
|
@@ -220,11 +226,10 @@ function registerMobileRoutes(deps) {
|
|
|
220
226
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
221
227
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
222
228
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
223
|
-
//
|
|
229
|
+
// 会话文件:允许本会话 present 声明的路径及会话工作区内的文件(见 mobile/session-file.js)。
|
|
224
230
|
route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
// 交付(卡片、正文链接),放行面跟着一次交付走;这条跟着「我登记过的目录」走。
|
|
231
|
+
// 文件浏览器按绝对路径取字节,范围是宿主已登记的工作区及当前会话的项目目录
|
|
232
|
+
//(见 mobile/file-read.js 与 mobile/allowed-zone.js)。
|
|
228
233
|
route('/api/mobile/v1/file/read', fileRead.handleFileRead)
|
|
229
234
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
230
235
|
// 「这一发到底进没进」:App 的离线发件箱在重发之前先问一句(按 clientMessageId 回查
|
|
@@ -284,9 +289,6 @@ function registerMobileRoutes(deps) {
|
|
|
284
289
|
route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
|
|
285
290
|
route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
|
|
286
291
|
route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
|
|
287
|
-
// App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
|
|
288
|
-
// (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
|
|
289
|
-
// 但插件从来没有注册 → 移动端「重命名工作区」和「归档会话」必然 404。
|
|
290
292
|
route('/api/mobile/v1/workspace/rename', workspaces.handleRenameWorkspace)
|
|
291
293
|
route('/api/mobile/v1/workspace/archive-session', workspaces.handleArchiveSession)
|
|
292
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