dsh-mobile-plugin 0.4.0 → 0.4.1
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/dsh-mobile-credentials/lib/credentials.js +8 -8
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -8
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +6 -27
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-file.js +8 -21
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 每设备凭据库:签发、校验、吊销手机端的持久化凭据。
|
|
5
5
|
*
|
|
6
|
-
* **凭据本身就是 Bearer
|
|
6
|
+
* **凭据本身就是 Bearer**。配对签发一枚
|
|
7
7
|
* `<instanceId>.<exp>.<random>`,同一枚同时充当 `Authorization: Bearer`、`dsh_lan` cookie
|
|
8
8
|
* 与 `X-Dsh-Device-Credential`。三段形状只为兼容老 App「实例号缺失时从第一段补」那条逻辑;
|
|
9
9
|
* 服务端**只按整串的 sha256 查库**,不看前缀、不解析三段、也不信里面的 `exp`
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 验签必须持有密钥本身,所以它按 base64url 原文落盘 —— 「整库只有哈希」这句话在它身上不成立。
|
|
14
14
|
* `hostToken` 只为旧的 HMAC 记录保留:新记录一律留空,代理直接转发请求里出示的那一枚。
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* 写入只有一个方向:宿主主进程是唯一写方;旁路子进程拿的是只读副本,靠文件
|
|
17
17
|
* mtime/size 变了就重载来跟住变化,签发与吊销走 IPC 交给主进程(`lib/impl.js`)。
|
|
18
18
|
*/
|
|
19
19
|
const crypto = require('node:crypto');
|
|
@@ -24,7 +24,7 @@ const os = require('node:os');
|
|
|
24
24
|
const FILE = process.env.DEVICE_CREDENTIALS_FILE || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'relay-device-credentials.json');
|
|
25
25
|
/** 默认有效期一年。`issue(..., ttlMs)` 可覆盖;`ttlMs <= 0` 表示不过期。 */
|
|
26
26
|
const DEFAULT_TTL_MS = 365 * 24 * 3600 * 1000;
|
|
27
|
-
/**
|
|
27
|
+
/** 已吊销或过期超过这么久的记录,下次写入时整条删掉:别让每条请求扫一整年的账。 */
|
|
28
28
|
const PRUNE_GRACE_MS = 30 * 24 * 3600 * 1000;
|
|
29
29
|
/** 剩余有效期超过这个比例就不续期:滑动续期只在过掉一半之后发生。 */
|
|
30
30
|
const RENEW_BELOW_RATIO = 0.5;
|
|
@@ -142,7 +142,7 @@ function deviceMeta(device) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
145
|
+
* 已吊销、或过期超过宽限期的整条删掉。
|
|
146
146
|
*
|
|
147
147
|
* 旧库只追加、吊销只打标记,今天不疼是因为「查不到就回退验 HMAC」把问题掩盖成了可用;
|
|
148
148
|
* 查库优先之后,每条请求都要扫整张表,一年下来几千条就是每条请求扫几千次。
|
|
@@ -257,7 +257,7 @@ function findByKeyId(instanceId, keyId) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
*
|
|
260
|
+
* 记一次「这台设备刚连上过」。
|
|
261
261
|
*
|
|
262
262
|
* 内存里每次都更新(面板 3 秒轮询要的是当下的值),落盘按全局 1 分钟节流:每条请求都写一次
|
|
263
263
|
* 盘,是拿磁盘换一个只用来显示「几分钟前」的字段。`issue` / `revoke` 立刻写,不受它影响。
|
|
@@ -269,7 +269,7 @@ function touch(record, info = {}) {
|
|
|
269
269
|
record.lastSeenAt = now;
|
|
270
270
|
const channel = typeof info.channel === 'string' ? info.channel : '';
|
|
271
271
|
if (channel) record.lastChannel = channel.slice(0, FIELD_MAX);
|
|
272
|
-
//
|
|
272
|
+
// 身份那几格:名字 / 平台 / 系统版本 / App 版本由**每一次请求**带上来,
|
|
273
273
|
// 顺手刷新。它们以前只在配对那一刻写一次,于是「手机升到新版了、面板还报配对时的旧版本」,
|
|
274
274
|
// 而旧会话升级写进来的记录干脆什么身份都没有(面板只能画「未知设备」)。
|
|
275
275
|
// **只写这次真带了值的格子**:老版本 App 不带头,绝不能把已经有的名字冲成空。
|
|
@@ -290,7 +290,7 @@ function touch(record, info = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
293
|
+
* 滑动续期:剩余不到一半就把 `expiresAt` 后推一个新 TTL。
|
|
294
294
|
*
|
|
295
295
|
* 不需要新协议 —— 服务端本来就以记录为准、不验 token 里的 `exp`,所以续期就是改这条记录。
|
|
296
296
|
* **不能改 token 本身**:库按整串哈希查,改一个字节手机上那枚就对不上了。
|
|
@@ -362,7 +362,7 @@ function listActive() {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
365
|
-
*
|
|
365
|
+
* 面板要的那份设备清单。
|
|
366
366
|
*
|
|
367
367
|
* 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。名字与平台取最新一条
|
|
368
368
|
* **有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),绑定时间取最早,最近连接取
|
package/host.js
CHANGED
|
@@ -2,16 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Headless Cordis Host for the DSH mobile app.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* it boots the same upstream Cordis Host, composes the shipped DSH Web
|
|
8
|
-
* bundle, and adds one plugin — the mobile API at `/api/mobile/v1`.
|
|
5
|
+
* Boots the upstream Cordis Host, composes the shipped DSH Web bundle, and adds
|
|
6
|
+
* one plugin — the mobile API at `/api/mobile/v1`.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* package's `cordis.patch.yml`.
|
|
8
|
+
* `@deepseek-ai/dsh-app-boot#boot` mounts a `cordis.yml` entry list, so the root
|
|
9
|
+
* list stays empty and every plugin comes from a patch layer: the profile's
|
|
10
|
+
* bundle layers (dsh-base then dsh-web-app), the profile's own
|
|
11
|
+
* `cordis.patch.yml`, and finally this package's `cordis.patch.yml`.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -67,24 +64,19 @@ try {
|
|
|
67
64
|
/**
|
|
68
65
|
* 本插件是**装在这个 profile 里**的,还是「源码树直接跑」的?
|
|
69
66
|
*
|
|
70
|
-
*
|
|
67
|
+
* 两种现场挂的不是同一份入口:
|
|
68
|
+
* - **装在 profile 里**(桌面端)→ 挂 `<profile>/node_modules/dsh-mobile-plugin/lib/index.js`,
|
|
69
|
+
* 即**稳定外壳**:查更新、装新版、就地换代都在它手里,面板上才有「检查 / 立即更新」。
|
|
70
|
+
* - **源码树直接跑**(无头部署、开发时 `node host.js`)→ 没有任何人把它放进 `node_modules`,
|
|
71
|
+
* 于是挂随包发布的 `lib/impl.js`。代价是**没有外壳**,那台只能同步源码 + 重启宿主。
|
|
71
72
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `lib/impl.js`(真正的实现)。代价是**没有外壳** → 那台的面板里更新不了,只能同步
|
|
78
|
-
* 源码 + 重启宿主。
|
|
73
|
+
* **两处都走绝对路径而不是包名**:`cordis.patch.yml` 里那行按包名挂载是给桌面端用的,
|
|
74
|
+
* 无头宿主这边解析不到 —— loader 从**它自己所在的位置**解析包名,而它在宿主自己的依赖树里,
|
|
75
|
+
* 看不见 profile 里装的那份。loader 对解析失败不是留一条警告,是**整棵插件树加载失败**,
|
|
76
|
+
* 所以那行在这里一律摘掉,由这里按绝对路径插一行。装没装 profile 决定的是**挂哪一份**,
|
|
77
|
+
* 不是挂不挂。
|
|
79
78
|
*
|
|
80
|
-
*
|
|
81
|
-
* 无头宿主这边解析不到 —— loader 是从**它自己所在的位置**解析包名的(实测报错原文:
|
|
82
|
-
* `Cannot find package 'dsh-mobile-plugin' imported from <…>/@deepseek-ai/cordis-plugin-loader/lib/index.js`),
|
|
83
|
-
* 而它在宿主自己的依赖树里,profile 里装的那份它根本看不见。而 loader 对解析失败不是留一条
|
|
84
|
-
* 警告,是**整棵插件树加载失败**(服务器那台当年就是这么完全起不来的)。所以那行在无头宿主
|
|
85
|
-
* 里一律摘掉,由这里按绝对路径插一行 —— 装没装 profile 决定的是**挂哪一份**,不是挂不挂。
|
|
86
|
-
*
|
|
87
|
-
* 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实,改不了口径。
|
|
79
|
+
* 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实。
|
|
88
80
|
*/
|
|
89
81
|
const installedDir = join(profileDir, 'node_modules', BIN_NAME)
|
|
90
82
|
const selfInstalled = existsSync(join(installedDir, 'package.json'))
|
|
@@ -230,17 +222,11 @@ try {
|
|
|
230
222
|
await assertEntriesActivated(ctx, BIN_NAME)
|
|
231
223
|
// 装载期结束,撤掉 installFailLoud 的兜底。
|
|
232
224
|
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// 紧接着一行 `fatal load failure` 就把 PID 换掉了。
|
|
239
|
-
//
|
|
240
|
-
// 这里在 assertEntriesActivated 通过之后才撤:那一刻树已经确认全部激活
|
|
241
|
-
//(激活失败的 entry 会在它内部 throw,进上面的 catch 走 boot failed),
|
|
242
|
-
// 此后再出现的 reject 就不再是「装载失败」而是运行期故障,
|
|
243
|
-
// 交给进程级错误边界记账 + 熔断。
|
|
225
|
+
// 它注册在 process 上之后**一辈子不摘**,而宿主是长驻进程 —— 运行期任意一次游离
|
|
226
|
+
// reject 都会走到它那里把进程带走,而它本该只管「装载失败」。所以这里在
|
|
227
|
+
// assertEntriesActivated 通过之后才撤:那一刻树已确认全部激活(激活失败的 entry 会在
|
|
228
|
+
// 它内部 throw,进上面的 catch 走 boot failed),此后的 reject 是运行期故障,交给
|
|
229
|
+
// 进程级错误边界记账 + 熔断。
|
|
244
230
|
releaseFailLoud()
|
|
245
231
|
process.stderr.write(`${BIN_NAME}: mobile host ready (profile ${PROFILE_NAME})\n`)
|
|
246
232
|
} catch (error) {
|
package/lib/client.js
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 四件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
|
|
5
|
+
* 提供「设置 → 移动端」那颗由本机打开的网页管理开关。方向是单向的(桌面 → 宿主 → 手机),
|
|
6
|
+
* 手机上的折叠只存在 App 本地,不写回。
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 的 createWorkspaceViewStore 持久化到 dsh-client-store 的 localStorage),
|
|
9
|
-
* 宿主进程、中转和手机都读不到。移动端要与桌面端对齐展开态,就只能由页面里的
|
|
10
|
-
* 这一半把状态读出来交给宿主。
|
|
11
|
-
*
|
|
12
|
-
* 数据方向是单向的:桌面 → 宿主 → 手机。手机上手动折叠/展开只存在 App 本地
|
|
13
|
-
* (AsyncStorage),不会写回这份 localStorage。
|
|
14
|
-
*
|
|
15
|
-
* 2. **设置 →「移动端」**(设置菜单里的顶层一项,不再挂在内置插件页签下)。保留
|
|
16
|
-
* **网页版入口**,并只加一颗由可信本机 DSH 设置页操作的公网访问开关;网页管理默认关闭,
|
|
17
|
-
* 每次开启固定 2 小时,到期不因访问续时。管理界面本体仍在宿主吐出的 HTML 里。
|
|
18
|
-
*
|
|
19
|
-
* 为什么这里只加开关:这半边不可热替换,管理界面继续留在网页版;本机开关只控制
|
|
20
|
-
* 网页管理路由,不改手机 App API 或隧道状态。插件更新仍交给 DSH 插件市场。
|
|
21
|
-
*
|
|
22
|
-
* 3. **桌面端已读 → 宿主**(`startDesktopReadSync`)。桌面侧栏的「已完成待读」绿点
|
|
23
|
-
* 只活在渲染进程内存里(`UiSession.completionUnread`),宿主和手机都读不到。
|
|
24
|
-
* 这一半盯着 `uiSession.sessionStatus`,把「刚从未读集合掉出去」的会话报给
|
|
25
|
-
* `/api/relay/desktop-read`,宿主据此清掉手机端那份完成提醒。
|
|
26
|
-
*
|
|
27
|
-
* 4. **手机端已读 → 桌面端**(`startReadReceiptSync`)。反方向:宿主把手机端读过
|
|
28
|
-
* 哪些会话记成 `sessionId → readAt` 台账,这一半拉下来,对水位前进过的会话做一次
|
|
29
|
-
* 瞬时 `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
|
|
30
|
-
* `reconcileStatus()` 把绿点摘掉。见 lib/read-receipt.cjs 的文件头。
|
|
31
|
-
*
|
|
32
|
-
* 写成惰性工厂(`window.__ModuleLoader__.load`)是 DSH 客户端插件的约定:
|
|
33
|
-
* 运行 bundle 只注册工厂,副作用(这里的定时器、页签注册)在物化时才发生。
|
|
34
|
-
* 工厂收到的 `require` 是 DSH 的模块表 require,`react` 是平台 seed word。
|
|
35
|
-
*
|
|
36
|
-
* 本文件**不经任何构建**(package.json 的 files 直接收录 lib 下的 .js),所以不能
|
|
37
|
-
* 写 JSX:统一用 `React.createElement` 的别名 `h`。
|
|
8
|
+
* 本文件不经任何构建(package.json 直接收录 lib 下的 .js),所以不能写 JSX,统一用
|
|
9
|
+
* `React.createElement` 的别名 `h`;客户端插件按约定写成惰性工厂,定时器与页签在物化时才起。
|
|
38
10
|
*/
|
|
39
11
|
|
|
40
12
|
window.__ModuleLoader__.load({
|
|
@@ -195,18 +167,11 @@ window.__ModuleLoader__.load({
|
|
|
195
167
|
}
|
|
196
168
|
|
|
197
169
|
/**
|
|
198
|
-
* 控制面调用。`path`
|
|
199
|
-
*
|
|
200
|
-
* `/api/relay/xxx` 或 `api/relay/xxx`——几种都接受,最终一律折成文档相对地址。
|
|
170
|
+
* 控制面调用。`path` 既接受 `/xxx` 这样的短路径,也接受已经带 `/api/relay` 的全路径,
|
|
171
|
+
* 几种都折成文档相对地址。
|
|
201
172
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* (`READ_RECEIPT_ENDPOINT` / `DESKTOP_READ_ENDPOINT`),于是真正发出去的是
|
|
205
|
-
* `/api/relay/api/relay/read-receipt` —— 一个不存在的地址。它返回 401/404,被
|
|
206
|
-
* 两条同步各自的 `catch` 静静吞掉,所以「手机上读过桌面不灭灯」「桌面上读过手机
|
|
207
|
-
* 不灭灯」从来就没通过,而且**一条日志都不留**(宿主侧活体台账里那两个计数恒为 0)。
|
|
208
|
-
* 改断言而不是改实现是不行的:守卫测试里的假 fetch 按 `endsWith(endpoint)` 匹配,
|
|
209
|
-
* 而 `/api/relay/api/relay/read-receipt` 也满足这个后缀 —— 测试因此一直是绿的。
|
|
173
|
+
* 必须两种都认:绿点那两条同步传进来的本来就是全路径,无条件拼前缀会发到
|
|
174
|
+
* `/api/relay/api/relay/...` 这个不存在的地址,而调用方的 catch 会把它静静吞掉。
|
|
210
175
|
*/
|
|
211
176
|
function controlUrl(path) {
|
|
212
177
|
const raw = String(path);
|
|
@@ -241,15 +206,14 @@ window.__ModuleLoader__.load({
|
|
|
241
206
|
});
|
|
242
207
|
|
|
243
208
|
/* ── 三:侧边栏未读绿点的双向同步 ───────────────────────────────────────
|
|
244
|
-
*
|
|
209
|
+
* 两端的绿点是两份互不相干的状态:
|
|
245
210
|
* - 手机端 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
|
|
246
211
|
* 由 `POST /api/mobile/v1/session/read` 清掉;
|
|
247
212
|
* - 桌面端 = 渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。
|
|
248
|
-
*
|
|
213
|
+
* 两边各缺一块,所以两个方向都要这一半补。
|
|
249
214
|
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* 所以每个 tick 现取一次,而不是启动时取一次。
|
|
215
|
+
* 两条同步都依赖桌面端的服务(uiSession / sessions),拿不到就整条不启动:注册了
|
|
216
|
+
* 也只是空转的定时器。服务可能比我们晚就位,所以每个 tick 现取一次。
|
|
253
217
|
*/
|
|
254
218
|
/** 轮询间隔:与展开态同步同频。绿点晚 2s 灭掉无感,但没必要更勤。 */
|
|
255
219
|
const READ_SYNC_INTERVAL_MS = 2000;
|
|
@@ -286,15 +250,11 @@ window.__ModuleLoader__.load({
|
|
|
286
250
|
/**
|
|
287
251
|
* 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
|
|
288
252
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* - 此刻**正开在主视图里**(`retainedBy.mainView > 0`:用户盯着它读完,它压根不进未读集合);
|
|
292
|
-
* - 本次打开页面之后**它变过、而且现在不是未读**(补掉漏采的那一瞬间)。
|
|
253
|
+
* 三种情形算「桌面读过了」,每种只报一次:看见它从未读里掉出去、此刻正开在主视图里
|
|
254
|
+
* (`retainedBy.mainView > 0`)、或本次页面里它变过且现在不是未读。
|
|
293
255
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* 未读全灭掉。所以「页面打开之后变过」这条要求 `updatedAt > 页面打开时刻`,
|
|
297
|
-
* 没拿到会话清单时宁可不报。
|
|
256
|
+
* 首帧一条都不报:页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`,
|
|
257
|
+
* 照单全收会把手机上真未读的全灭掉。所以要求 `updatedAt > 页面打开时刻`。
|
|
298
258
|
*/
|
|
299
259
|
function startDesktopReadSync(ctx) {
|
|
300
260
|
withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
|
|
@@ -381,15 +341,9 @@ window.__ModuleLoader__.load({
|
|
|
381
341
|
* 拿到桌面客户端的两个服务:`uiSession`(未读绿点的真源 + 每会话运行态)与
|
|
382
342
|
* `sessions`(会话清单 + `retain`)。
|
|
383
343
|
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* effect 是**无条件 2s 轮询**、`ctx.get` 之后才 fetch,所以只可能是 tick 在服务这一
|
|
388
|
-
* 道 guard 上就返回了。同一个桌面里能正常工作的客户端插件(内置的 conversation /
|
|
389
|
-
* approval、第三方 `dsh-opencode-go`)一律走 `inject`:DSH 自己的插件把服务写进
|
|
390
|
-
* `inject` 数组,opencode-go 用 `ctx.inject([...], child => …)`。`ctx.get` 是「绕过
|
|
391
|
-
* 注入直接取」,在客户端运行时里取不到这两个服务。拿不到就一个定时器都不起(与旧
|
|
392
|
-
* 行为一致),由 lib/client.js 的自检上报把「到底看见了什么」说给宿主听。
|
|
344
|
+
* 必须用 `ctx.inject([...], child => …)`,不能用 `ctx.get(name)`:`ctx.get` 是绕过注入
|
|
345
|
+
* 直接取,在客户端运行时里取不到这两个服务,两条绿点同步会一条都不跑。拿不到就一个
|
|
346
|
+
* 定时器都不起,由自检上报把「到底看见了什么」说给宿主听。
|
|
393
347
|
*/
|
|
394
348
|
/**
|
|
395
349
|
* 客户端半边的自检状态:只给自检上报用(谁注入了、谁没注入)。
|
|
@@ -437,16 +391,12 @@ window.__ModuleLoader__.load({
|
|
|
437
391
|
* 方向二:手机上读过了 → 在桌面页面里就地清掉绿点。
|
|
438
392
|
*
|
|
439
393
|
* 拉宿主那份 `sessionId → readAt` 台账,对水位前进过的会话做一次瞬时
|
|
440
|
-
* `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()
|
|
441
|
-
* `reconcileStatus()`
|
|
442
|
-
*
|
|
443
|
-
* 发生;release 把引用数降回 0 也不会把绿点加回来(只有 `observeRunning(false)`
|
|
444
|
-
* 且非 mainView 才会加)。整个过程同一个 tick 内完成,React 还没重渲染,看不到抖动。
|
|
394
|
+
* `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()`:`retain` 会同步
|
|
395
|
+
* 抬 `retainedBy.mainView`,桌面自己的 `reconcileStatus()` 因此摘掉绿点,而 release
|
|
396
|
+
* 把引用数降回 0 也不会把它加回来。同一个 tick 内完成,React 还没重渲染,看不到抖动。
|
|
445
397
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* 早已被新一轮完成盖过的旧回执重新应用,把本该亮的绿点灭掉。会话的最后活动时间
|
|
449
|
-
* 晚于已读水位,就说明这一轮是读完之后才跑完的,必须跳过。
|
|
398
|
+
* 还要跟 `updatedAt` 比一次:台账不记「读完之后又跑完了一轮」,重新打开页面时会把
|
|
399
|
+
* 旧回执重新应用,灭掉本该亮的绿点。最后活动时间晚于已读水位就跳过。
|
|
450
400
|
*/
|
|
451
401
|
function startReadReceiptSync(ctx) {
|
|
452
402
|
withClientServices(ctx, ['sessions'], 'sessions', 'mobile-plugin: 已读回执应用', (scope, isDisposed) => {
|
|
@@ -682,14 +632,11 @@ window.__ModuleLoader__.load({
|
|
|
682
632
|
/**
|
|
683
633
|
* 客户端半边的自检上报:每 10s 把「这一半到底看见了什么」报给宿主。
|
|
684
634
|
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* 「服务拿到了但清理失败」两种可能耗掉了一整轮排查 —— 这条通路把答案直接摆出来:
|
|
688
|
-
* 两条同步各自的 `ctx.inject` 到底有没有回调(`uiSession` / `sessions` 两格)、
|
|
689
|
-
* `ctx.get` 能不能看见那两个服务、最后一次错在哪。
|
|
635
|
+
* 宿主能数出「那半边没来敲门」,数不出「为什么没来」。这条通路直接摆出:两条同步各自的
|
|
636
|
+
* `ctx.inject` 有没有回调、`ctx.get` 能不能看见那两个服务、最后一次错在哪。
|
|
690
637
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
638
|
+
* 刻意不依赖任何客户端服务:探测全包在 try/catch 里,它自己挂在服务上就永远报不出
|
|
639
|
+
* 「服务拿不到」这件事。
|
|
693
640
|
*/
|
|
694
641
|
function startClientHalfReport(ctx) {
|
|
695
642
|
const probe = (name) => {
|
package/lib/desktop-view.cjs
CHANGED
|
@@ -3,17 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 桌面端工作区视图快照(侧边栏展开/折叠)的宿主侧存储。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 宿主进程、中转和手机都读不到它。插件因此带了一个客户端半边(lib/client.js):
|
|
10
|
-
* 桌面页面里每 2 秒读一次这份状态,变了就 POST `/api/relay/desktop-view`,
|
|
11
|
-
* 由本模块落到 `$DSH_HOME/desktop-view.json`,再由 `/api/mobile/v1/desktop-view`
|
|
12
|
-
* 下发给 App。
|
|
6
|
+
* 桌面端只把展开态记在浏览器 localStorage 里,宿主、中转和手机都读不到;插件因此带了
|
|
7
|
+
* 客户端半边(lib/client.js):桌面页面定期读出来 POST `/api/relay/desktop-view`,由本模块
|
|
8
|
+
* 落到 `$DSH_HOME/desktop-view.json`,再由 `/api/mobile/v1/desktop-view` 下发给 App。
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 还没有记录」时会自动展开它并落盘,所以「当前会话所在工作区」通常也在里面。
|
|
10
|
+
* 语义与桌面端一致:`groupExpansion` 以 workspaceId 为键(未分组固定为空串),只有显式
|
|
11
|
+
* true 表示展开,缺省即折叠;当前会话所属分组桌面端会自动展开,所以通常也在里面。
|
|
17
12
|
*/
|
|
18
13
|
|
|
19
14
|
const fs = require('node:fs');
|
package/lib/git/read.js
CHANGED
|
@@ -383,12 +383,11 @@ function githubTargetOf(remote) {
|
|
|
383
383
|
/**
|
|
384
384
|
* GitHub 元信息(星标 / Fork / 关注)。
|
|
385
385
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
386
|
+
* 只取这三个数字,不取星标曲线:曲线要把 stargazers 一页页翻完,几千星的仓库等于几十次
|
|
387
|
+
* 请求,撞上匿名限流就整页变慢。有 `GITHUB_TOKEN` / `GH_TOKEN` 时带上,私有仓库也能读。
|
|
388
388
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* 私有仓库靠这一个头就能读,不必在 App 里输任何东西。
|
|
389
|
+
* 「读不到」和「没有 GitHub 远端」要分开:私有仓库的匿名请求回 404,是两件事,前端那句
|
|
390
|
+
* 话必须说对。
|
|
392
391
|
*/
|
|
393
392
|
async function githubOf(root) {
|
|
394
393
|
const remotes = await remotesOf(root);
|
|
@@ -564,9 +563,8 @@ async function activityOf(root) {
|
|
|
564
563
|
/**
|
|
565
564
|
* 仓库概览:远端、语言占比、代码量、近 12 周活动,外加(能取到时)GitHub 的三个数字。
|
|
566
565
|
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* 不给。它不该把整页拖垮。
|
|
566
|
+
* 这张卡最贵,所以结果按仓库缓存一分钟(`fresh` 跳过),每项都自己兜底:语言统计有读取
|
|
567
|
+
* 上限,历史统计失败退化成 0,GitHub 取不到就整块不给。它不该把整页拖垮。
|
|
570
568
|
*/
|
|
571
569
|
async function overviewOf(root, options = {}) {
|
|
572
570
|
const cached = overviewCache.get(root);
|
package/lib/host-compat.cjs
CHANGED
|
@@ -3,23 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 让「官方工具行」跟着运行时换代,而不是跟着本包发版那一刻。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `disabling profile plugin row "tool-ask-user"` 的启动日志。现象就是手机上
|
|
11
|
-
* 「模型说没有这个工具」「添加工作区列不出目录」,而桌面上一切正常。
|
|
6
|
+
* 提问工具、交付文件工具、目录浏览后端这三个包互相用精确版本咬死,而 `dsh-app-boot` 在
|
|
7
|
+
* 装载前拿每一行跟运行时对一遍——对不上就整行禁用,现象是手机上「模型说没有这个工具」。
|
|
8
|
+
* 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
|
|
9
|
+
* 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - `host.js` 启动时先问运行时那棵树要**同代**的那一份(判据直接用
|
|
16
|
-
* `dsh-app-boot` 自己导出的兼容性判定,跟装载前那次检查完全同源);
|
|
17
|
-
* - 拿到了就把预设行与该 patch 行的 `name` 换成那份的**入口绝对路径**,
|
|
18
|
-
* Loader 按路径装载,绕开「从谁的位置按裸名解析」这个坑;
|
|
19
|
-
* - 拿不到就保持原样,让官方那次检查去禁用并打印原因——退回当前行为,不会更差。
|
|
20
|
-
*
|
|
21
|
-
* 这个文件只放纯函数(复制目录、改文本、改 patch 行),不 import 任何 ESM 包:
|
|
22
|
-
* `host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
11
|
+
* 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
23
12
|
*/
|
|
24
13
|
|
|
25
14
|
const fs = require('node:fs')
|
|
@@ -28,8 +17,8 @@ const path = require('node:path')
|
|
|
28
17
|
/**
|
|
29
18
|
* 预设里那两行的 id 与包名。
|
|
30
19
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
20
|
+
* 两个都要:都是官方工具、都精确咬版本、都不在 `dsh-web-app` 的预设里,漏掉一个对应
|
|
21
|
+
* 能力就会同样安静地消失。
|
|
33
22
|
*/
|
|
34
23
|
const TOOL_ROWS = Object.freeze([
|
|
35
24
|
Object.freeze({ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' }),
|
|
@@ -48,9 +37,8 @@ const ROW_NAME = /^(\s+)name:\s*'([^']+)'\s*$/
|
|
|
48
37
|
/**
|
|
49
38
|
* 把 `- id: <id>` 紧跟着的那个 `name:` 换成给定值。
|
|
50
39
|
*
|
|
51
|
-
* 逐行扫而不是解析 YAML
|
|
52
|
-
*
|
|
53
|
-
* 只认「上一行是目标 id」的 name,所以同一个包名在别处出现(注释、其它行)不会被误伤。
|
|
40
|
+
* 逐行扫而不是解析 YAML 再序列化:这份预设带着大量解释性注释,解析再写回会把它们和
|
|
41
|
+
* 空行全洗掉。只认「上一行是目标 id」的 name,同一个包名出现在别处不会被误伤。
|
|
54
42
|
*
|
|
55
43
|
* @param {string} source 预设文件内容
|
|
56
44
|
* @param {Record<string, string>} targets 行 id → 新的 name(入口绝对路径)
|
|
@@ -82,8 +70,8 @@ function rewriteToolNames(source, targets) {
|
|
|
82
70
|
/**
|
|
83
71
|
* 把预设目录同步一份到 `outDir`,并把目标行换成本次运行时的入口。
|
|
84
72
|
*
|
|
85
|
-
* 每次启动都重写:两份 yml 加起来不到 8KB
|
|
86
|
-
*
|
|
73
|
+
* 每次启动都重写:两份 yml 加起来不到 8KB,而「只在变化时写」要多记一份状态,就多一处
|
|
74
|
+
* 能不一致的地方。
|
|
87
75
|
*
|
|
88
76
|
* @param {string} presetDir 包内 `config/agent-presets`
|
|
89
77
|
* @param {string} outDir 宿主可写的目标目录
|
|
@@ -106,8 +94,8 @@ function syncPresetDir(presetDir, outDir, targets) {
|
|
|
106
94
|
/**
|
|
107
95
|
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
108
96
|
*
|
|
109
|
-
* 只改 `insert`
|
|
110
|
-
*
|
|
97
|
+
* 只改 `insert` 里的行:其它形态的 `name` 是断言,与原名相同才会应用,改它等于让整条
|
|
98
|
+
* patch 被静默跳过。
|
|
111
99
|
*
|
|
112
100
|
* @param {Array} patches 已加载的 patch 层
|
|
113
101
|
* @param {Record<string, string>} targets 包名 → 入口绝对路径
|
package/lib/host-guard.cjs
CHANGED
|
@@ -3,44 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 宿主进程级错误边界(crash guard)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* DSH 宿主是长驻进程(嵌入桌面版或由 systemd 以 `Restart=on-failure` 托管),任何逃出
|
|
7
|
+
* 事件循环的异常都会带走整个 DSH。路由层已有 `.catch`,进程层的缺口是两处:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 带走整个 DSH——用户那边表现为「DSH 崩了/插件把 DSH 搞挂了」,而且桌面端连个
|
|
11
|
-
* 插件隔离层都没有,插件的 bug 直接就是宿主的 bug。
|
|
9
|
+
* 1. 游离的 Promise 拒绝——Node 15+ 默认 throw 并杀进程,插件里大量 fire-and-forget;
|
|
10
|
+
* 2. 第三方回调(事件流、WS、timer)里抛错,没有 try 包着就是 uncaughtException。
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 1. **游离的 Promise 拒绝**。Node 15+ 起 `unhandledRejection` 默认行为是 *throw*,
|
|
18
|
-
* 也就是**杀进程**。插件里凡是 `void someAsync()`(fire-and-forget)的地方,
|
|
19
|
-
* 一个没 catch 的 reject 就等于一次 DSH 崩溃。这类点极多:自动归档轮询、
|
|
20
|
-
* 镜像推送、隧道重连、遥测上报……任意一个后台任务炸一次就够了。
|
|
21
|
-
* 2. **第三方回调里抛错**。宿主事件流、WS 回调、timer 回调抛出的异常没有 try 包裹,
|
|
22
|
-
* 直接升级为 `uncaughtException`。
|
|
23
|
-
*
|
|
24
|
-
* ## 策略:记录 + 保持存活,连续 5 次才退出
|
|
25
|
-
*
|
|
26
|
-
* 单个异常通常不致命(最常见的是"某个后台任务这一次没成")。一刀切退出等于把
|
|
27
|
-
* 偶发故障变成必然崩溃;一味吞掉又会掩盖真正的崩溃循环。所以取中间值:
|
|
28
|
-
*
|
|
29
|
-
* - **记录**:每一条都进内存账本,带时间/类型/消息/是否致命,供 `status()` 暴露
|
|
30
|
-
* (「及时阻止」的前提是能看见);
|
|
31
|
-
* - **保持存活**:`unhandledRejection` 与单次 `uncaughtException` 都只记录并继续跑,
|
|
32
|
-
* 不退出。这把「插件的偶发 bug」和「DSH 崩溃」解耦了;
|
|
33
|
-
* - **熔断**:只有 `uncaughtException` 计入致命计数,且**必须落在时间窗内**。
|
|
34
|
-
* 超过 `windowMs` 没再犯就重新计数——这样捕获的是「崩溃循环」(秒级连炸),
|
|
35
|
-
* 而不是「跑三个月偶发 5 次」的正常长驻。真正熔断时再主动退出,交给
|
|
36
|
-
* `Restart=on-failure` / 桌面端重启拉起,拿到一个干净的新进程。
|
|
37
|
-
*
|
|
38
|
-
* ## 为什么把状态机和进程接线拆开
|
|
39
|
-
*
|
|
40
|
-
* `createGuard()` 是纯状态机(时间/日志/退出都注入),测试可以完全确定性地驱动它,
|
|
41
|
-
* 不必真的往 `process` 上挂处理器、也不会在跑测试时把测试进程搞死;
|
|
42
|
-
* `installProcessGuard()` 才是那层薄薄的进程接线。这个拆分是本模块能被守卫测试
|
|
43
|
-
* 覆盖的前提。
|
|
12
|
+
* 策略:全部记入内存账本并保持存活;只有 uncaughtException 计入熔断,且必须落在时间窗
|
|
13
|
+
* 内连发到 `fatalCount` 才主动退出,交给 `Restart=on-failure` 拉起。`createGuard()` 是
|
|
14
|
+
* 纯状态机,`installProcessGuard()` 才是进程接线,拆分是为了能被守卫测试覆盖。
|
|
44
15
|
*/
|
|
45
16
|
|
|
46
17
|
const FATAL_FAULT_COUNT = 5;
|
|
@@ -187,9 +158,8 @@ function activeGuard() { return installed; }
|
|
|
187
158
|
/**
|
|
188
159
|
* 把一个可能抛/可能 reject 的调用包成「永不把异常带出去」的函数。
|
|
189
160
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* 也再也不会有一处忘了 catch 就把 DSH 带走。
|
|
161
|
+
* 插件里大量 fire-and-forget 的调用点可以套这一层,不必各自 try/catch,也就不会
|
|
162
|
+
* 因为漏了一处 catch 把 DSH 带走。
|
|
193
163
|
*
|
|
194
164
|
* @param {Function} fn 原始函数
|
|
195
165
|
* @param {string} [label] 出错时用于定位的来源名
|
package/lib/host-patches.cjs
CHANGED
|
@@ -8,23 +8,12 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* 摘掉「按裸包名挂载本插件」的 patch 行。
|
|
10
10
|
*
|
|
11
|
-
* `cordis.patch.yml`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `cordis.patch.yml` 里那行 `insert: [{ id: dsh-mobile, name: 'dsh-mobile-plugin' }]` 是
|
|
12
|
+
* 给桌面端用的:插件管理器把包装进 profile 的 `node_modules`,Loader 从那里按包名解析。
|
|
13
|
+
* 无头宿主没有那次安装,解析不到包名时 loader 会让整棵插件树加载失败,而不是留一条警告。
|
|
14
|
+
* 宿主自己用绝对路径另插一行 `mobile-api`,所以这行在这里只有害处。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* `node_modules` —— 于是这一行解析不到。而 loader 对解析失败**不是**留一条警告,是整棵树
|
|
17
|
-
* 加载失败:
|
|
18
|
-
*
|
|
19
|
-
* dsh-mobile-plugin: boot failed: Error: dsh-mobile-plugin: plugin tree failed to load:
|
|
20
|
-
* failed to apply loader entry include (cordis:include): failed to import loader entry
|
|
21
|
-
* dsh-mobile (dsh-mobile-plugin): Cannot find package 'dsh-mobile-plugin'
|
|
22
|
-
* code: 'ERR_MODULE_NOT_FOUND'
|
|
23
|
-
*
|
|
24
|
-
* (实测:服务器宿主从引入这一行的提交起就再也起不来了。)宿主自己用绝对路径另插一行
|
|
25
|
-
* `mobile-api` 挂载本插件,所以这一行在这里只有害处。
|
|
26
|
-
*
|
|
27
|
-
* 不改 `cordis.patch.yml` 本身:桌面端还要靠它。只在无头宿主装载前从内存里摘掉。
|
|
16
|
+
* 不改 `cordis.patch.yml` 本身(桌面端还要靠它),只在无头宿主装载前从内存里摘掉。
|
|
28
17
|
*
|
|
29
18
|
* @param {Array} patches 已加载的 patch 层(loadOverlayPatches 的返回值)
|
|
30
19
|
* @param {string} binName 本插件的包名
|
package/lib/http/routing.js
CHANGED
|
@@ -3,14 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 插件 HTTP 路由基建:控制面与移动端两套守卫的共用地基。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 恰好是最该有测试的地方:它们挡的是「谁能调控制面」「谁能调移动端」,写错一行的
|
|
9
|
-
* 后果是鉴权被绕过而不是某个页面显示不出来。
|
|
10
|
-
*
|
|
11
|
-
* 抽出来单独放一个文件是为了让它们能被直接 require 并喂假 req/res 测试。抽出后
|
|
12
|
-
* 两套守卫并排放在一个文件里,肉眼就能对照出「控制面多挡了跨站与 method,移动端只
|
|
13
|
-
* 挡 token」这个刻意的不对称。
|
|
6
|
+
* 都是只依赖 req/res 的纯函数,抽到这里才能直接 require 并喂假 req/res 测试。它们挡的是
|
|
7
|
+
* 「谁能调控制面」「谁能调移动端」,写错一行的后果是鉴权被绕过,不是页面显示不出来。
|
|
14
8
|
*/
|
|
15
9
|
|
|
16
10
|
const net = require('node:net');
|
|
@@ -123,29 +117,15 @@ const localOnly = (method, handler) => async (req, res) => {
|
|
|
123
117
|
|
|
124
118
|
const readJson = (req) => readJsonBody(req, 256 * 1024).then(body => body || {})
|
|
125
119
|
|
|
126
|
-
/**
|
|
127
|
-
* 移动端路由守卫:token + 把 handler 抛出的异常统一收成 500 信封。
|
|
128
|
-
*
|
|
129
|
-
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
130
|
-
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
131
|
-
*
|
|
132
|
-
* @param {object} ctx 插件 ctx,透传给 handler
|
|
133
|
-
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
134
|
-
* @param {() => void} [note] 通过 token 校验之后叫一声:宿主用它记「手机来过」的时刻,
|
|
135
|
-
* 引导最后一页据此说「手机已经连上了」。**只在校验通过之后叫**,401 那条不算——
|
|
136
|
-
* 否则随便谁打一下也能把面板骗成「连上了」。
|
|
137
|
-
* @returns {(handler: (ctx: object, req: object, res: object) => Promise<unknown>) => (req: object, res: object) => Promise<unknown>}
|
|
138
|
-
*/
|
|
139
120
|
/**
|
|
140
121
|
* 移动端路由守卫:token + 协议 v2 信封 + 把 handler 抛出的异常统一收成 500 信封。
|
|
141
122
|
*
|
|
142
123
|
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
143
124
|
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
144
125
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* 因为 App 必须先在解不开信封的情况下读懂它。
|
|
126
|
+
* 信封顺序定死:鉴权(token)→ 验签 → 重放 → 解封请求体 → 业务 handler → 封响应。
|
|
127
|
+
* 鉴权在最前,因为「这台设备是谁」决定拿哪把密钥验签;鉴权自身失败回明文 401,
|
|
128
|
+
* App 必须能在解不开信封时读懂它。
|
|
149
129
|
*
|
|
150
130
|
* @param {object} ctx 插件 ctx,透传给 handler
|
|
151
131
|
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
@@ -251,8 +231,8 @@ const createGuarded = (ctx, authorized, note = null, extra = {}) => {
|
|
|
251
231
|
const sealedPayload = sealResponse(req, { statusCode: chosenStatus }, sealed.keys, sealed.req, parsed)
|
|
252
232
|
// **封完直接发,不再 gzip**:`content-encoding` 只能在头出去之前声明,而这个
|
|
253
233
|
// 钩子跑在 `sendJson` 的 `writeHead` 之后,`setHeader` 会被静默忽略 —— 结果
|
|
254
|
-
// 是「线上是 gzip、头却说 identity」,客户端解不开,报「请求失败 (HTTP 200)
|
|
255
|
-
//
|
|
234
|
+
// 是「线上是 gzip、头却说 identity」,客户端解不开,报「请求失败 (HTTP 200)」。
|
|
235
|
+
// 信封是 base64 密文,外层压缩本来也只省几个百分点;
|
|
256
236
|
// 要省带宽靠载荷裁剪,不靠这一层。
|
|
257
237
|
payload = JSON.stringify(sealedPayload)
|
|
258
238
|
}
|