dsh-mobile-plugin 0.4.0 → 0.4.4
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/README.md +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +47 -24
- 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 -12
- package/lib/index.js +1 -1
- 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/allowed-zone.js +100 -0
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +13 -21
- package/lib/mobile/attachments.js +16 -37
- 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/file-read.js +168 -0
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +85 -134
- 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 +48 -23
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +6 -23
- 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 +136 -33
- 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-cwd.js +28 -0
- package/lib/mobile/session-file.js +9 -33
- 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 +154 -37
- package/lib/mobile/stats-ledger.cjs +258 -75
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +39 -130
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +75 -21
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +9 -12
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +39 -47
- package/lib/routes/mobile-api.js +47 -34
- package/lib/session-cleanup.cjs +26 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/device-headers.js +12 -4
- 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 +15 -28
- package/lib/sidecar-facade-worker.cjs +75 -41
- package/lib/sidecar-facade.cjs +18 -8
- package/lib/sidecar-lifecycle.cjs +21 -10
- package/lib/update-install.cjs +24 -62
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
|
@@ -3,21 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 清单内容指纹(sessions / workspaces 共用)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 两处要用:列表响应带 `revision`,App 连同 `generatedAt` 存进快照就能说清数据是什么时候的;
|
|
7
|
+
* 镜像推送每 500ms 完整构建一次清单,内容没变时比对 revision 就能安全跳过。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* sessions + workspaces,内容没变时推送一份字节完全相同的 payload 纯属浪费,
|
|
12
|
-
* 比对 revision 就能安全跳过。
|
|
13
|
-
*
|
|
14
|
-
* 为什么用内容哈希而不是自增序号:插件随桌面 harness 频繁重启,内存里的序号每次
|
|
15
|
-
* 都从 0 开始,重启后第一份快照会因为「序号变了」而被当成新内容重推一遍;内容哈希
|
|
16
|
-
* 跨重启稳定(同样的清单 → 同样的 revision),这才是判重想要的性质。
|
|
17
|
-
*
|
|
18
|
-
* 为什么用 sha1 而不是更省的 32 位哈希:这个值会被用来**跳过推送**,一次碰撞就等于
|
|
19
|
-
* 一份该推的新数据被静默丢掉。sha1 足够便宜(清单量级下每 tick 亚毫秒),不值得为
|
|
20
|
-
* 几个字节冒这个风险。截断到 16 个十六进制字符只是为了让日志短一点。
|
|
9
|
+
* 用内容哈希而不是自增序号:插件随桌面 harness 频繁重启,序号每次都从 0 开始,重启后第一份
|
|
10
|
+
* 快照会被当成新内容重推。用 sha1 而不是 32 位哈希:这个值会被用来跳过推送,一次碰撞等于
|
|
11
|
+
* 一份该推的新数据被静默丢掉。截断到 16 个十六进制字符只是为了让日志短一点。
|
|
21
12
|
*/
|
|
22
13
|
|
|
23
14
|
const crypto = require('node:crypto');
|
|
@@ -3,24 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 设备级「某个会话的正文水位涨了」信号总线。
|
|
5
5
|
*
|
|
6
|
-
* 与 list-signal
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 列表」时,运行中的助手回复只能等下一次点开该会话才被拉下来。
|
|
6
|
+
* 与 list-signal(清单变了)形状相同,但管的是另一种场景:手机上没打开某个会话时,那条会话
|
|
7
|
+
* 的正文没人订阅,运行中的回复只能等下次点开才拉下来。插件本来就把正文推进了中转镜像,缺的
|
|
8
|
+
* 只是「这个会话的正文又长了,可以来取」这句通知。
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 「这个会话的正文又长了,你(手机)可以来取」这一句通知,本模块就是那个出口。
|
|
10
|
+
* 清单信号是全局的(一个 revision),这条是按会话的(sessionId → 已广播的消息水位 seq)。
|
|
11
|
+
* 水位取快照里真正装上的消息的最大 seq,与 App 侧缓存水位同一个 seq 空间。
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* (见 pusher 的 messageWatermarkOf),也就是 App 侧会话缓存的水位口径(同一个 seq 空间),
|
|
18
|
-
* 手机拿它和自己的缓存水位一比就知道该不该增量拉一次。
|
|
19
|
-
*
|
|
20
|
-
* 为什么是模块级单例:生产端(mirror/pusher.cjs,CJS)与消费端(mobile/events.js,CJS)
|
|
21
|
-
* 都 require 到同一个实例,两边不需要认识对方;测试可以 createSessionSignal() 造独立实例。
|
|
22
|
-
* 为什么订阅者抛错要吞掉:bus 的调用方是镜像推送链,一条写坏的 SSE 连接不能把推送拖垮
|
|
23
|
-
* (真故障会从连接自身的 close 反映出来)。
|
|
13
|
+
* 做成模块级单例:生产端与消费端 require 到同一个实例,互不认识;测试可以自己造实例。
|
|
14
|
+
* 订阅者抛错要吞掉——bus 的调用方是镜像推送链,一条写坏的 SSE 连接不能把推送拖垮。
|
|
24
15
|
*/
|
|
25
16
|
|
|
26
17
|
const NOOP_RELEASE = () => {};
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* SSE 写帧的唯一实现:单会话流(lib/mobile/stream.js)与设备级流(lib/mobile/events.js
|
|
5
|
-
* 共用这一份。
|
|
4
|
+
* SSE 写帧的唯一实现:单会话流(lib/mobile/stream.js)与设备级流(lib/mobile/events.js)共用。
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 现在「这条连接上到底积压了多少字节」「软/硬水位怎么判」「SSE 记录长什么样」「稳定
|
|
11
|
-
* 事件 id 怎么算」「写不动时怎么等 drain」都只在这里,两条流只在调用点上声明自己的策略。
|
|
6
|
+
* 合并是因为两份实现会让同一个坑修两遍:宿主 OOM 时先修了会话流那份,隔一轮才发现设备流那份
|
|
7
|
+
* 还漏着。现在积压字节数、软/硬水位、SSE 记录形状、稳定事件 id、等 drain 都只在这里,两条流
|
|
8
|
+
* 只在调用点声明自己的策略。
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 所以「断开重连」是它的正常收敛路径。
|
|
22
|
-
* - 填充:会话流每条记录补 2048 字节 SSE 注释(Android RN 的 XHR 会攒小 chunk,
|
|
23
|
-
* 不补就迟迟不触发 onprogress);设备级流的帧很少、单帧也小,不补。
|
|
10
|
+
* 两条流刻意保留的差异(改之前先看理由):
|
|
11
|
+
* - 丢帧口径:会话流的软水位只丢明确可丢的增量(`assistant/chunk` 草稿,收尾的
|
|
12
|
+
* `assistant/message` 带完整正文);`session/queue`、`session/jobs`、`resync-required` 这些
|
|
13
|
+
* 控制帧没有 seq 但绝不能丢。设备级流的帧全是幂等信号,丢掉后由调用方断流重连并补发。
|
|
14
|
+
* - 写不动时:会话流 await 一次 drain(20s 超时收尾);设备级流直接收尾让客户端重连,重连
|
|
15
|
+
* 时会补发当前状态,所以断开是它的正常收敛路径。
|
|
16
|
+
* - 填充:会话流每条补 2048 字节注释(Android RN 的 XHR 会攒小 chunk,不补迟迟不触发
|
|
17
|
+
* onprogress);设备级流帧少且小,不补。
|
|
24
18
|
*/
|
|
25
19
|
|
|
26
20
|
const crypto = require('crypto');
|
|
@@ -66,8 +60,8 @@ function ssePadding(bytes) {
|
|
|
66
60
|
* 这条连接上真正「还没发出去」的字节数。
|
|
67
61
|
*
|
|
68
62
|
* 只看 `res.writableLength` 是错的:它只算响应对象自己那一层,真正的积压在底下的套接字
|
|
69
|
-
* 写队列里(堆快照里那些 `{chunk, encoding, callback}
|
|
70
|
-
*
|
|
63
|
+
* 写队列里(堆快照里那些 `{chunk, encoding, callback}`)。实测只看前者时闸门从不触发,
|
|
64
|
+
* 内存照涨。所以顺着 socket 一起看,取较大值。
|
|
71
65
|
*/
|
|
72
66
|
function bufferedBytesOf(res) {
|
|
73
67
|
const own = Number(res.writableLength) || 0;
|
|
@@ -2,23 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
// 空清单守卫(工作区 / 会话列表共用)。
|
|
4
4
|
//
|
|
5
|
-
// 「没取到」与「真的什么都没有」在单份快照上长得一模一样:都是
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `host-session-list-degraded`),守卫则负责兜住「宿主确实回了合法空列表」这种情况
|
|
9
|
-
// ——它和真实删除无法区分,所以按时间兜底。
|
|
5
|
+
// 「没取到」与「真的什么都没有」在单份快照上长得一模一样:都是 `{ ok: true, items: [] }`。
|
|
6
|
+
// 读取侧必须如实报错(见 workspaces.js / sessions.js 的 `*-list-degraded`),守卫负责兜住
|
|
7
|
+
// 「宿主确实回了合法空列表」——单看一份快照区分不了它和真实删除,所以按时间兜底。
|
|
10
8
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// 设备仍显示「在线」(那是另一条独立的 /status 探测线),点不开,且全程零错误提示。
|
|
9
|
+
// 一份空快照若被当成权威清单推给中转,下一次 15s 轮询就会把好数据覆盖成空:侧边栏工作区
|
|
10
|
+
// 整行消失、会话塌进「未分组」,而且全程零错误提示。所以距上一次确认过的非空清单不到
|
|
11
|
+
// graceMs 时拒绝空清单、保留上一份镜像;超过 graceMs 则放行,让真实删除也能收敛。
|
|
15
12
|
//
|
|
16
|
-
//
|
|
17
|
-
// 距上一次确认过的非空清单还不到 graceMs 时拒绝空清单、保留上一份镜像;超过 graceMs
|
|
18
|
-
// 则放行,让真实删除也能收敛,不会把侧边栏永久钉死在旧数据上。
|
|
19
|
-
//
|
|
20
|
-
// 抽成独立模块是为了能被真正单测(早期版本把这段逻辑内联在 lib/index.js 的闭包里,
|
|
21
|
-
// 只能靠 grep 源码做契约测试,行为本身一次都跑不到)。
|
|
13
|
+
// 抽成独立模块是为了能被真正单测。
|
|
22
14
|
|
|
23
15
|
const fs = require('node:fs')
|
|
24
16
|
|
package/lib/shell/device-ota.js
CHANGED
|
@@ -7,12 +7,10 @@ import { movePath, readManifest, sha256Hex, unpackTarGz, verifyManifest } from '
|
|
|
7
7
|
/**
|
|
8
8
|
* OTA 包落在哪。
|
|
9
9
|
*
|
|
10
|
-
* 默认 `<DSH_HOME>/plugin-ota
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 第一句 `require('@deepseek-ai/dsh-tools')` 就 MODULE_NOT_FOUND(2026-10-17 真机:
|
|
15
|
-
* 0.1.80 装载失败)。落在 profile 里之后,OTA 代与装进来的那一代走**同一条**解析链。
|
|
10
|
+
* 默认 `<DSH_HOME>/plugin-ota`,外壳知道 profile 在哪之后会把它挪进 profile 里面。原因是
|
|
11
|
+
* DSH 宿主对 `profiles/` 树外的模块走原生解析,而宿主提供的 peer 不在 profile 的
|
|
12
|
+
* node_modules 里,OTA 代第一句 require 就 MODULE_NOT_FOUND。落在 profile 里之后,OTA 代与
|
|
13
|
+
* 装进来的那一代走同一条解析链。
|
|
16
14
|
*/
|
|
17
15
|
let storageRoot = ''
|
|
18
16
|
export function configureOtaRoot(dir) {
|
package/lib/shell/manager.js
CHANGED
|
@@ -1,31 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 代际装载器:把「插件实现」当成可以整代替换的东西。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 拉起来的,重启一条命令的事;桌面这边用户得自己退出再打开,而他不想每次都做这件事。
|
|
4
|
+
* 宿主启动时就把 profile 的插件树定死了,改一次插件代码就得完整重启 DSH(Node 缓存
|
|
5
|
+
* realpath);桌面用户不想每次都做这件事,所以要能在进程里换代。
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* 正式版本由 DSH 插件市场装进 profile,紧急 OTA 把完整包落在 profile 里,两者都由这里切换。
|
|
8
|
+
* pnpm 装新版会把 `node_modules/dsh-mobile-plugin` 指向一个全新的 store 目录 ——「新目录 =
|
|
9
|
+
* 新 realpath = 新 URL」,天然绕开 ESM 缓存;所以只要认出 profile 里当前装的是哪一份,把它
|
|
10
|
+
* 当成一代接上去就够了,不需要拷贝或校验。
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* <profile>/node_modules/dsh-mobile-plugin -> .pnpm/dsh-mobile-plugin@0.1.58/node_modules/dsh-mobile-plugin
|
|
14
|
-
*
|
|
15
|
-
* 「新目录 = 新 realpath = 新 URL」,正是当年必须靠 `.live/<releaseId>` 才能拿到的东西
|
|
16
|
-
* (见 `importGeneration` 里 `?v=` 那一段:同一路径换代码是骗不过 ESM 缓存的)。所以这里
|
|
17
|
-
* 只要**认出 profile 里当前装的是哪一份**,把它当成一代接上去就够了 —— 不需要拷贝、
|
|
18
|
-
* 不需要下载,也不需要 sha256 校验(那是市场的活儿)。
|
|
19
|
-
*
|
|
20
|
-
* 一代 = 一个完整包目录,`package.json` 与 `lib/impl.js` 缺一不可:`lib/*.js` 内部有
|
|
21
|
-
* 80 多处相对引用(`../shared/http-util` 之类),必须保持 `lib/` 这一层的相对结构;
|
|
22
|
-
* 实现里还有 `require('../dsh-mobile-credentials/lib/credentials.js')` 这种跨包相对引用,
|
|
23
|
-
* 而凭据包就嵌在插件包目录内部,所以一代是**自包含**的:回滚到旧代时连凭据版本一起
|
|
24
|
-
* 回滚,不会出现「旧插件配新凭据」。
|
|
25
|
-
*
|
|
26
|
-
* 裸包名(`ws`、`@deepseek-ai/...`)依旧沿祖先目录解析:从 `<代>/lib` 往上会经过
|
|
27
|
-
* `<代>/node_modules`、profile 的 `node_modules`,最终落在同一份插件自身依赖与 peer
|
|
28
|
-
* 依赖上,与基础代解析到同一份 real path,不会出现两份实例。
|
|
12
|
+
* 一代 = 一个完整包目录,`package.json` 与 `lib/impl.js` 缺一不可:`lib/*.js` 内部有大量
|
|
13
|
+
* 相对引用,凭据包也嵌在插件包目录内部,所以一代是自包含的——回滚时连凭据版本一起回滚。
|
|
14
|
+
* 裸包名沿祖先目录解析,与基础代落到同一份依赖上。
|
|
29
15
|
*/
|
|
30
16
|
|
|
31
17
|
import fs from 'node:fs'
|
|
@@ -249,8 +235,8 @@ function evictCommonJsTree(roots) {
|
|
|
249
235
|
* 宿主对模块解析有一层拦截:**`profiles/` 树内**的模块,裸包名会先按 profile 的
|
|
250
236
|
* 本地包解析、再回落到宿主自己那份;树外的模块走原生解析。而宿主提供的那批 peer
|
|
251
237
|
* (`@deepseek-ai/dsh-tools` 等)并不在 profile 的 `node_modules` 里 —— 所以 OTA 包
|
|
252
|
-
* 一旦落在树外,那一代 `require('@deepseek-ai/dsh-tools')` 就是 MODULE_NOT_FOUND
|
|
253
|
-
*
|
|
238
|
+
* 一旦落在树外,那一代 `require('@deepseek-ai/dsh-tools')` 就是 MODULE_NOT_FOUND。
|
|
239
|
+
* 位置挪进 profile 后,OTA 代与装进来的那一代
|
|
254
240
|
* 走同一条解析链,不必再靠调用点逐个打补丁。
|
|
255
241
|
*
|
|
256
242
|
* 老位置残留的包顺手搬过来:换代失败时它还是磁盘上的回退包,不能在升级路径上丢。
|
|
@@ -282,8 +268,7 @@ function moveOtaRootIntoProfile() {
|
|
|
282
268
|
* Node 只沿**自己的祖先目录**找 `node_modules`,而 OTA 落在
|
|
283
269
|
* `<DSH_HOME>/plugin-ota/<version>/` —— 这条路径上没有任何一份 `node_modules`。于是
|
|
284
270
|
* `lib/impl.js` 第一句 `import { z } from '@deepseek-ai/schemastery'` 就报
|
|
285
|
-
* `Cannot find package
|
|
286
|
-
* 外壳按规矩回滚到旧代,机器上什么都没变)。
|
|
271
|
+
* `Cannot find package`,一代整个装不起来。外壳按规矩回滚到旧代,机器上什么都没变。
|
|
287
272
|
*
|
|
288
273
|
* 修法是补上基础代本来就有、而 OTA 路径上没有的那两层软链:
|
|
289
274
|
* `<version>/node_modules` → profile 的 `node_modules`
|
package/lib/shell/ota-package.js
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OTA 包(npm tarball)的读取、校验与落盘。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 长路径就直接抛「不支持的 tar 项」;反过来,包里少几个文件它又看不出来,于是装上去之后
|
|
7
|
-
* 才在某一代运行时报「找不到某个 JS 文件」。这里把两件事一起解决:
|
|
4
|
+
* 这一层只为「换一台机器也照样能装」:手写解包只认普通文件和目录,遇到 PAX 扩展头、GNU 长名
|
|
5
|
+
* 或长路径就抛错,而包里少几个文件又看不出来。这里把两件事一起解决:
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 每个文件的路径和字节数),解包后逐项核对。缺文件、大小不符都在**激活之前**失败,
|
|
14
|
-
* 错误里直接点名是哪个文件。
|
|
7
|
+
* 1. 解包要认得真实世界里的 tar:ustar 的 prefix、PAX、GNU 长名都要能读;软链接、硬链接、
|
|
8
|
+
* 设备文件明确拒绝并说清原因,不静默跳过。
|
|
9
|
+
* 2. 装完要能证明完整:包里带一份发布时生成的清单(版本 + 每个文件的路径与字节数),解包后
|
|
10
|
+
* 逐项核对,缺文件或大小不符都在激活之前失败并点名。
|
|
15
11
|
*
|
|
16
|
-
*
|
|
17
|
-
* DSH 的机器上被解出来,不能要求它先装点别的),而 ustar 的头部格式是固定的 512 字节。
|
|
12
|
+
* 手写而不是引依赖:这是发布链路上要能在一台刚装好 DSH 的机器上跑的自包含代码。
|
|
18
13
|
*/
|
|
19
14
|
|
|
20
15
|
import crypto from 'node:crypto'
|
package/lib/shell/runtime.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 代际运行时:把「外壳」和「某一代实现」缝在一起,并负责换代那一刻的秩序。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 照常 `ctx.effect(...)` / `ctx.webServer.register(...)` / `ctx.reflect.provide(...)`。
|
|
4
|
+
* 外壳只做两件事:启动时激活版本最高的一代,以及把换代请求转给这里。实现侧拿到的是一个普通
|
|
5
|
+
* 的 cordis `Context`,不知道自己是被外壳装进来的。
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* 换代必须串行、且先停后起:一代在 `apply` 里会注册同一条路径的路由(撞路径直接抛错)、绑
|
|
8
|
+
* sidecar、起镜像推送,只能先 dispose 旧代(每个 `ctx.effect` 反向清理),再装新代。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 卸旧的」——只能先把旧代的 fiber dispose 掉(它的每个 `ctx.effect` 会反向执行清理:路由
|
|
13
|
-
* 摘掉、子进程收掉),再装新的。这个文件里的 `swapping` 锁就是保证同一时刻只有一次换代。
|
|
14
|
-
*
|
|
15
|
-
* ## 让路
|
|
16
|
-
*
|
|
17
|
-
* 手机那边随时可能有一个请求在读事件流。直接 dispose 会把 `ctx.effect` 的清理跑在别人的
|
|
18
|
-
* 请求中间。所以换代前先等 `inFlight` 归零,最多等 `DRAIN_TIMEOUT_MS`(等不到也照换——
|
|
19
|
-
* 卡住的请求不该拖住一次修复性更新)。
|
|
10
|
+
* 换代前先等 `inFlight` 归零,最多等 `DRAIN_TIMEOUT_MS`——把清理跑在别人的请求中间会出事,
|
|
11
|
+
* 但卡住的请求也不该拖住一次修复性更新。
|
|
20
12
|
*/
|
|
21
13
|
|
|
22
14
|
import { importGeneration, invalidateGeneration, resolveGeneration, resolveGenerations } from './manager.js'
|
|
@@ -199,7 +191,7 @@ export function createRuntime() {
|
|
|
199
191
|
* 旧代先停、新代后起;新代起不来就回滚把旧代装回去,并把错误留在 `lastError` 里——
|
|
200
192
|
* 「更新把自己弄挂了」必须能被看见,而不是静默少个插件。
|
|
201
193
|
*
|
|
202
|
-
* `options.reread`
|
|
194
|
+
* `options.reread` 是给**开发机**的那条路:版本号与目录都没变,只是盘上
|
|
203
195
|
* 那几个文件被改了。默认会走上面那句「身份一致就直接返回」,于是改完代码再触发也没有
|
|
204
196
|
* 任何变化 —— 开发时看到的「改了不生效」就是这么来的。带上它就先把这一代从
|
|
205
197
|
* `imported` 里作废、再让 `importGeneration` 清掉 CJS 子树,强制重新读盘。
|
|
@@ -288,7 +280,7 @@ export function createRuntime() {
|
|
|
288
280
|
/**
|
|
289
281
|
* 第一次激活;最高代装载失败时按优先级尝试保留的回退代;全失败则按固定间隔重试有限次。
|
|
290
282
|
*
|
|
291
|
-
*
|
|
283
|
+
* 为什么要重试:一次失败 = **整张控制面路由表都不在**,而设置页
|
|
292
284
|
* 的「移动端」照样画得出来(客户端半边按包扫描,不看宿主 fiber 活没活),于是现场只表现成
|
|
293
285
|
* 「读取宿主状态失败: HTTP 404」。失败最常见的原因恰好是**可能自己好**的:注入的服务
|
|
294
286
|
* (typertGateway / apiProxy)比本插件晚就位、撞上 20s 装载上限,或者第一次装载撞上
|
|
@@ -1,30 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 换代之后的自检:新版装起来之后,手机要打的那批接口真的能用吗?不能就退回上一代。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 这条不能交给手机:装坏的后果是 App 连不上这台设备,那一刻手机上的回滚按钮发不出去。
|
|
5
|
+
* 必须由还活着的宿主做完,手机只在事后读到「上次装失败了,已自动退回 X」。
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 判据是打本机的 `127.0.0.1:<宿主端口>`:200 且 `service: 'dsh-mobile-plugin'` 算活;401 也
|
|
8
|
+
* 算活(守卫先核 token,说明实现与守卫都在);404 / 别的 service / 超时算死。连不上
|
|
9
|
+
* (ECONNREFUSED)不算数——那是宿主 webServer 自己没起来,与新代无关,退回去也救不了,
|
|
10
|
+
* 如实报 `skip`。
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 不看日志、不猜堆栈,直接打本机的 `127.0.0.1:<宿主端口>`:
|
|
13
|
-
*
|
|
14
|
-
* · 回 200 + `service: 'dsh-mobile-plugin'` → 活,路由挂上了;
|
|
15
|
-
* · 回 401 → 也活:移动端守卫先核 token 再谈路由,没带 token 回 401 说明实现与守卫都在;
|
|
16
|
-
* · 回 404 / 别的 service → **死了**:端口在、但插件那批路由没挂上,手机打过去就是这个 404;
|
|
17
|
-
* · 超时 → **死了**:连上了但不回包,多半是某个 handler 卡在等一个永远不来的依赖;
|
|
18
|
-
* · 连不上(ECONNREFUSED)→ **不算数**:那是宿主 webServer 自己没起来,与新代无关,
|
|
19
|
-
* 退回去也救不了。如实报 `skip`。
|
|
20
|
-
*
|
|
21
|
-
* 最后那一条是这次刻意分开的:把「宿主没在监听」当成「新版坏了」会退掉一次其实成功的
|
|
22
|
-
* 更新(开发机上跑测试时就是这么一种现场:假 webServer 有个端口号,但没有任何东西在听)。
|
|
23
|
-
*
|
|
24
|
-
* ## 端口从哪儿来
|
|
25
|
-
*
|
|
26
|
-
* 用宿主的 `ctx.webServer.port`(运行中那一份),不是配置里的默认值 —— 端口被改过之后,
|
|
27
|
-
* 配置与实绑可能不同,按配置去问会问到一个没人监听的地方,于是「自检失败」全是假的。
|
|
12
|
+
* 端口用 `ctx.webServer.port`(运行中那份),不是配置里的默认值:端口被改过之后按配置去问
|
|
13
|
+
* 会问到一个没人监听的地方。
|
|
28
14
|
*/
|
|
29
15
|
|
|
30
16
|
/** 自检的超时上限:本机环回,正常几十毫秒。给 3 秒已经很宽。 */
|
package/lib/shell/updater.js
CHANGED
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 更新编排:查与装都归实现自己,装完就地换代(不重启 DSH)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 面板那颗「更新」按钮只覆盖「用户正开着页面点一下」;自动更新的定义是没人看着也要发生,
|
|
5
|
+
* 所以轮询与「装完切换」都必须在宿主进程里做。
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* 查问 npm 注册表、装在 profile 里跑包管理器(`update-registry.cjs` / `update-install.cjs`)。
|
|
8
|
+
* 外壳只从盘上那一代把自装模块取进来跑,所以换装法不用等重启。下面那套市场实现是兜底,只在
|
|
9
|
+
* 盘上那一代还没有自装模块时走到;市场装完会报 `restartRequired`,但本插件有外壳可以自己
|
|
10
|
+
* 换代,所以那条路里故意不调它的重启。
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 从前「查」和「装」都是请 DSH 插件市场代劳的(打它的 HTTP 接口)。现在两件都归实现自己:
|
|
12
|
-
* 查问 npm 注册表、装在 profile 里跑它自己的包管理器(`lib/update-registry.cjs` /
|
|
13
|
-
* `lib/update-install.cjs`)。外壳**只从盘上那一代**把自装模块取进来跑(`installedInstaller`),
|
|
14
|
-
* 所以换装法、换判定都不用等重启 —— 盘上换了,下一次自动更新就走新的。
|
|
15
|
-
*
|
|
16
|
-
* 下面那套市场实现是**兜底**,只在「盘上那一代还没有自装模块」时才走到(老一代、刚回滚到
|
|
17
|
-
* 旧代)。自动更新宁可暂时还走老路,也不能直接坏掉;市场装完会报 `restartRequired: true`,
|
|
18
|
-
* 而本插件有外壳可以自己换代,所以那条路里**故意不调它的重启**。
|
|
19
|
-
*
|
|
20
|
-
* 装完新一代是**新目录**(包管理器换链接),外壳照着新 realpath 重新 import 一代即可,见
|
|
21
|
-
* `lib/shell/manager.js` 与 `lib/shell/runtime.js`。
|
|
22
|
-
*
|
|
23
|
-
* ## 兜底那条路不做的事
|
|
24
|
-
*
|
|
25
|
-
* 不下载、不解包、不校验、不写 profile —— 全是市场的职责。它只回答四个问题:有没有新版、
|
|
26
|
-
* 请它装、装完了没、盘上现在是什么版本。
|
|
12
|
+
* 兜底那条路不下载、不解包、不校验、不写 profile,只回答「有没有新版、装完了没、盘上是什么
|
|
13
|
+
* 版本」。
|
|
27
14
|
*/
|
|
28
15
|
|
|
29
16
|
import fs from 'node:fs'
|
|
@@ -40,7 +27,7 @@ const { dshHome } = require_('../shared/paths.js')
|
|
|
40
27
|
/**
|
|
41
28
|
* 盘上那一代的「自己装」模块(`lib/update-install.cjs`)。
|
|
42
29
|
*
|
|
43
|
-
*
|
|
30
|
+
* 「装」这件事归实现自己:在 profile 里跑它自己的包管理器,不再借道 DSH
|
|
44
31
|
* 插件市场。外壳只负责把盘上那一代的模块取进来跑 —— 从**盘上**(不是外壳自己的目录)取,
|
|
45
32
|
* 是为了「装法改了不用等重启」:市场以外的方式换掉那一代之后,下一次自动更新就走新装法。
|
|
46
33
|
*
|
|
@@ -100,7 +87,7 @@ export function shellFingerprint(pkgDir) {
|
|
|
100
87
|
export const PACKAGE_NAME = 'dsh-mobile-plugin'
|
|
101
88
|
|
|
102
89
|
/** 自动检查的间隔与「检查一次」的超时。 */
|
|
103
|
-
export const AUTO_INTERVAL_MS =
|
|
90
|
+
export const AUTO_INTERVAL_MS = 2 * 60 * 1000
|
|
104
91
|
const REQUEST_TIMEOUT_MS = 15000
|
|
105
92
|
/** 等一次安装走完的上限:pnpm 装一个包 + 校验,给足但别无限等。 */
|
|
106
93
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
|
|
@@ -229,12 +216,12 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
|
229
216
|
*/
|
|
230
217
|
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, auto = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
|
|
231
218
|
const before = installedVersion()
|
|
232
|
-
//
|
|
219
|
+
// 装归实现自己:盘上那一代里有 `update-install.cjs` 就走它。判定(有没有
|
|
233
220
|
// 新版、`link:` 来源不碰、有 agent 在跑就先别换文件)也一并交给它,别在这里抄第二份 ——
|
|
234
221
|
// 外壳的自动更新与面板那颗按钮必须是同一套判定,否则会出现「手动说有新版、自动说没有」。
|
|
235
222
|
//
|
|
236
|
-
// `auto`
|
|
237
|
-
//
|
|
223
|
+
// `auto` 也透下去:自动那一轮跳过「有对话在跑就先别装」那道闸(取舍见
|
|
224
|
+
// `update-install.cjs` 文件头),但没有新版时它照样什么都不做。
|
|
238
225
|
const selfInstall = await installedInstaller()
|
|
239
226
|
if (selfInstall) {
|
|
240
227
|
const outcome = await selfInstall({
|
|
@@ -318,7 +305,7 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
|
|
|
318
305
|
/**
|
|
319
306
|
* 换代尝试的记账:同一个版本自动只试一次。
|
|
320
307
|
*
|
|
321
|
-
*
|
|
308
|
+
* 一个起不来的新版每一轮把手机抖一下没有任何意义 —— 第一次失败之后就该停下,等人按
|
|
322
309
|
* 那颗按钮(手动不受这个限制:那是「我知道,再来一次」)。进程重启后这份记录清零,
|
|
323
310
|
* 而重启本来就会走一遍正常装载,不需要它。
|
|
324
311
|
*/
|
|
@@ -3,24 +3,24 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* sidecar worker:真正持有「中转隧道 + 局域网代理」的那个子进程。
|
|
5
5
|
*
|
|
6
|
-
* 宿主不自己开 WebSocket、也不自己监听局域网端口,而是 fork 出这个 worker,用环回
|
|
7
|
-
*
|
|
8
|
-
* 失败都只死一个子进程,宿主仍能如实把状态报给 /api/relay/status。
|
|
6
|
+
* 宿主不自己开 WebSocket、也不自己监听局域网端口,而是 fork 出这个 worker,用环回 HTTP +
|
|
7
|
+
* Bearer 令牌对话。隧道崩了、原生模块加载失败都只死一个子进程,宿主仍能如实回报状态。
|
|
9
8
|
*
|
|
10
9
|
* 三条不能忘的语义:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
10
|
+
* 1. 状态既要在 `/status` 上可查,也要在变化那一刻主动 `process.send` 给宿主:健康轮询是
|
|
11
|
+
* 10s 一次,只靠轮询的话隧道真断后宿主最多 10s 仍以为连着;「断又连」落在两次轮询之间时
|
|
12
|
+
* 那次全量补推也会漏掉。
|
|
13
|
+
* 2. `/tunnel/send` 先把快照留在本地再尝试真发:局域网直连的手机读本地这份,隧道断了也不
|
|
14
|
+
* 至于读到过期镜像。
|
|
15
|
+
* 3. 请求体一律先量后读:`/configure` 与镜像帧都有字节上限,超限立刻 413。
|
|
16
|
+
* 4. 换中转地址不是「拆了重建」:先在旧隧道上登记迁移,再建新隧道,注册不上要撤销
|
|
17
|
+
* (见 lib/relay/migrate.js)。这条顺序是「填错地址就把自己锁在外面」的唯一解。
|
|
19
18
|
*/
|
|
20
19
|
|
|
21
20
|
const http = require('node:http')
|
|
22
21
|
const { RelayTunnel } = require('./relay/tunnel.js')
|
|
23
22
|
const { T } = require('./relay/protocol.js')
|
|
23
|
+
const { switchRelay } = require('./relay/migrate.js')
|
|
24
24
|
const { createLanProxy } = require('./relay/lan-proxy.js')
|
|
25
25
|
const { lanUrls } = require('./relay/lan-network.cjs')
|
|
26
26
|
const core = require('./relay/core.js')
|
|
@@ -98,15 +98,12 @@ function authorized(req) { return typeof token === 'string' && token.length >= 6
|
|
|
98
98
|
class BodyTooLarge extends Error {}
|
|
99
99
|
/** /tunnel/send 的请求体上限。
|
|
100
100
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* 旧的 1MB 上限是在把有用的东西挡在门外。
|
|
101
|
+
* 这个接口收桌面推给移动端的镜像:整份会话列表、工作区、当前会话的完整消息与统计。桌面一次
|
|
102
|
+
* 推送就超过 1MB,旧的 1MB 上限把有用的东西挡在门外。
|
|
104
103
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* 丢一次镜像可以恢复,隧道被关掉要等重连、期间手机端全瞎——所以宁可拒也不能赌。
|
|
109
|
-
* 留 64KB 余量给帧头。推得再大也只是这一次 413,隧道安然无恙。
|
|
104
|
+
* 依据是中转那侧的帧上限(默认 8MB):一帧超过它,中转会以 1009 'frame too large' 直接关掉
|
|
105
|
+
* 隧道。丢一次镜像可以恢复,隧道被关掉要等重连、期间手机端全瞎。所以留 64KB 余量给帧头,
|
|
106
|
+
* 推得再大也只是这一次 413。
|
|
110
107
|
*/
|
|
111
108
|
const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
|
|
112
109
|
|
|
@@ -277,10 +274,14 @@ function stopLan() {
|
|
|
277
274
|
publishState()
|
|
278
275
|
}
|
|
279
276
|
async function stopServices() { stopNetworkMonitor(); stopTunnel(); stopLan() }
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
277
|
+
/**
|
|
278
|
+
* 建一条隧道并把它设成当前实例,返回它的注册 Promise(resolve = 已 registered)。
|
|
279
|
+
*
|
|
280
|
+
* **不在这里吞错**:换中转地址那条路要拿到真实的失败原因去撤销迁移登记、并把错误回给调用方
|
|
281
|
+
* (`configure` 的 503 会一路回到手机)。普通重建由 `startTunnel` 那一侧兜底 —— 状态置错 +
|
|
282
|
+
* 排重连,与旧实现一字不差。
|
|
283
|
+
*/
|
|
284
|
+
function openTunnel() {
|
|
284
285
|
const current = new RelayTunnel({
|
|
285
286
|
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId,
|
|
286
287
|
name: config.name, upstream: upstream(),
|
|
@@ -311,7 +312,17 @@ function startTunnel() {
|
|
|
311
312
|
onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
|
|
312
313
|
})
|
|
313
314
|
tunnel = current
|
|
314
|
-
|
|
315
|
+
const ready = current.start()
|
|
316
|
+
// 挂了就如实报,并按 5s 起退避重连(旧实现在 startTunnel 的 catch 里做同一件事)。
|
|
317
|
+
// 换地址失败时这里也会排一次:配置里已经是新地址,值得继续试它,而不是悄悄退回旧地址。
|
|
318
|
+
ready.catch(error => { if (tunnel === current) { tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect() } })
|
|
319
|
+
return ready
|
|
320
|
+
}
|
|
321
|
+
function startTunnel() {
|
|
322
|
+
if (!config.enabled || !config.relayUrl || !config.upstreamPort) return
|
|
323
|
+
stopTunnel(); tunnelState = 'connecting'
|
|
324
|
+
publishState()
|
|
325
|
+
void openTunnel()
|
|
315
326
|
}
|
|
316
327
|
function startLan() {
|
|
317
328
|
// 总开关关闭时不能只停隧道:旧实现漏了 `config.enabled`,于是 /api/relay/toggle
|
|
@@ -377,13 +388,35 @@ async function configure(next) {
|
|
|
377
388
|
// - 其余(配对码 / 名字 / 局域网候选)在现有连接上重报一次注册信息即可。
|
|
378
389
|
// 无条件全拆全建就是「每隔几分钟断一次 + 内存一直涨」的根因,别改回去。
|
|
379
390
|
const previous = config
|
|
391
|
+
// 中转地址变了要**先登记迁移再重建**:还握着旧地址的设备只会在旧中转上找这台电脑。
|
|
392
|
+
// 按规范形态比,避免只差一个尾斜杠就白跑一次迁移(那只是同一台中转)。
|
|
393
|
+
// 「第一次配地址」不算换地址:旧中转上没有谁握着这台电脑的旧地址,没什么要登记的,
|
|
394
|
+
// 更不该为了等注册把启动那次下发拖住。
|
|
395
|
+
const previousRelayUrl = core.normalizeRelayUrl(previous.relayUrl)
|
|
396
|
+
const relayUrlChanged = Boolean(previousRelayUrl) && previousRelayUrl !== core.normalizeRelayUrl(next.relayUrl)
|
|
380
397
|
const tunnelNeedsRebuild = tunnelSignature(previous) !== tunnelSignature(next)
|
|
381
398
|
const lanNeedsRebuild = lanSignature(previous) !== lanSignature(next)
|
|
382
399
|
const monitorNeedsRestart = monitorSignature(previous) !== monitorSignature(next)
|
|
383
400
|
config = { ...next }
|
|
384
401
|
if (tunnelNeedsRebuild) {
|
|
385
|
-
|
|
386
|
-
|
|
402
|
+
if (relayUrlChanged && config.enabled && config.relayUrl && config.upstreamPort) {
|
|
403
|
+
// 换地址这条路**等新隧道真的注册上**再返回:注册不上要把错误一路回给调用方,
|
|
404
|
+
// 并在旧隧道上撤销迁移登记(顺序与理由见 lib/relay/migrate.js 文件头)。
|
|
405
|
+
await switchRelay({
|
|
406
|
+
oldTunnel: tunnel,
|
|
407
|
+
relayUrl: config.relayUrl,
|
|
408
|
+
open: () => {
|
|
409
|
+
// 旧隧道还在,但重连定时器得先撤掉:否则它到点会把刚起来的新隧道又拆一遍。
|
|
410
|
+
reconnect.cancel()
|
|
411
|
+
tunnelState = 'connecting'; tunnelError = null; publishState()
|
|
412
|
+
return openTunnel()
|
|
413
|
+
},
|
|
414
|
+
drop: (old) => { if (old) { try { old.stop() } catch { /* 已经断开 */ } } },
|
|
415
|
+
})
|
|
416
|
+
} else {
|
|
417
|
+
stopTunnel()
|
|
418
|
+
startTunnel()
|
|
419
|
+
}
|
|
387
420
|
}
|
|
388
421
|
if (lanNeedsRebuild) {
|
|
389
422
|
stopLan()
|
|
@@ -418,7 +451,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
418
451
|
if (!authorized(req)) return send(res, 401, { ok: false, error: 'unauthorized' })
|
|
419
452
|
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { ok: true, service: 'dsh-ai-hub-relay-sidecar', capabilities: ['relay-tunnel', 'lan-proxy', 'pairing-approval-rpc'] })
|
|
420
453
|
if (req.method === 'GET' && req.url === '/status') return send(res, 200, status())
|
|
421
|
-
if (req.method === 'POST' && req.url === '/configure') {
|
|
454
|
+
if (req.method === 'POST' && req.url === '/configure') {
|
|
455
|
+
const next = await readBody(req, CONFIGURE_MAX_BODY)
|
|
456
|
+
const run = configuring.then(() => configure(next))
|
|
457
|
+
// 串行链本身**不许被一次失败卡死**:`configuring` 一旦是 rejected,后续每一个
|
|
458
|
+
// `.then(() => configure(...))` 都会跳过 configure 直接跟着失败 —— 一次地址填错
|
|
459
|
+
// 就能让这台宿主从此不再接受任何配置下发(隧道永远起不来,只能重启 worker)。
|
|
460
|
+
configuring = run.catch(() => undefined)
|
|
461
|
+
return send(res, 200, await run)
|
|
462
|
+
}
|
|
422
463
|
if (req.method === 'POST' && req.url === '/tunnel/send') {
|
|
423
464
|
const body = await readBody(req, TUNNEL_SEND_MAX_BODY);
|
|
424
465
|
// 桌面→relay 主动推送(移动端镜像缓存):复用隧道立即 sendJson;同时在本地保留快照供读取隔离。
|
|
@@ -464,22 +505,15 @@ process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown)
|
|
|
464
505
|
/**
|
|
465
506
|
* 父进程(宿主)没了就自己退出:否则这里会变成一具握着隧道的尸体。
|
|
466
507
|
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* 隧道、继续如实回答 `/api/mobile/v1/status`(它知道的宿主身份还是那一份),中转的
|
|
471
|
-
* `instances` 里那条记录就永不删除 —— 手机侧栏的设备「在线」从此再也灭不掉,正文
|
|
472
|
-
* 镜像也停在最后一帧。2026-10-10 真机故障,根因就在这里。
|
|
508
|
+
* 宿主被 Cmd+Q、被系统清掉或被 SIGKILL 时,没有人会给这里发 SIGTERM,IPC 也只是静默 EOF。
|
|
509
|
+
* worker 继续活着、继续握着那条注册成功过的隧道,中转的 instances 里那条记录就永不删除,
|
|
510
|
+
* 手机侧栏的设备「在线」再也灭不掉。
|
|
473
511
|
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* - ppid 巡检:宿主被 SIGKILL 时**不发** disconnect(实测只看到 ppid 变成 1),
|
|
477
|
-
* 只能靠定时对账。启动时记下 ppid,之后对不上就退出;间隔与宿主那边的健康轮询
|
|
478
|
-
* 同量级(10s),2s 只是让「关掉桌面 → 手机显示离线」快一点。
|
|
512
|
+
* 两道判据互相兜底:`disconnect` 在宿主正常退出时立刻触发(最快);ppid 巡检兜住 SIGKILL
|
|
513
|
+
* (实测只看到 ppid 变成 1,不发 disconnect),启动时记下 ppid,之后对不上就退出。
|
|
479
514
|
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
* 最快的收敛路径(也让 `ps` 里不再留下一行认不出归属的 node 进程)。
|
|
515
|
+
* 整进程退出而不是只 `stopServices()`:隧道一关,中转要等心跳超时才删实例,这期间手机仍
|
|
516
|
+
* 显示在线;孤儿进程没有存在的理由,退出是最快的收敛路径。
|
|
483
517
|
*/
|
|
484
518
|
const PARENT_WATCH_INTERVAL_MS = 2000
|
|
485
519
|
const parentPidAtStart = process.ppid
|