ygc-dsh-plugin-autoupdate 0.7.0 → 0.9.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/README.md +152 -8
- package/client.js +41 -0
- package/index.js +65 -7
- package/lib/api.js +36 -0
- package/lib/core.js +6 -1
- package/lib/pidtree.js +115 -0
- package/lib/restart-boot.js +45 -0
- package/lib/restart-helper.js +90 -46
- package/lib/restart.js +373 -53
- package/lib/uninstall.js +176 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -32,20 +32,96 @@
|
|
|
32
32
|
|
|
33
33
|
**插件不自己改 `cordis.patch.yml`,不卸载任何插件,不碰 `~/.dsh/workbuddy-vault`。**
|
|
34
34
|
|
|
35
|
+
> 括号里的「不卸载插件」是 0.7.x 及更早的边界。0.8.0 起**新增了受控卸载**,
|
|
36
|
+
> 见下面「卸载插件」一节:只卸 profile 自装且宿主判定 removable 的包,走宿主官方接口。
|
|
37
|
+
|
|
35
38
|
## 自动重启怎么做的
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
**两条通道,官方优先。**
|
|
41
|
+
|
|
42
|
+
### 通道一:宿主优雅重启(首选)
|
|
43
|
+
|
|
44
|
+
宿主若暴露 `desktopRuntime.requestRestart()`(`ctx.get('desktopRuntime')` 或
|
|
45
|
+
`ctx.get('desktopActions')`),插件直接调它。桌面 shell 自己 `app.relaunch()` 并无确认退出,
|
|
46
|
+
把这次退出当作**预期内**的退出,因此**不会弹「启动失败」恢复框、不写 crash report**。
|
|
39
47
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
### 通道二:进程树之外的助手(兜底)
|
|
49
|
+
|
|
50
|
+
拿不到官方通道时才走这条。插件不自杀——它把重启助手**送到调用树之外**,由助手负责:
|
|
51
|
+
|
|
52
|
+
1. 等 `restartDelaySeconds`(默认 0 秒)
|
|
53
|
+
2. **等旧 Electron main 自己退出**(默认最多 15 秒,`exitWaitMs` 可调)——它自己退了就一步强制动作都不做
|
|
54
|
+
3. 仍没退出,先 `taskkill /PID <main>`(**不带 `/F`**,等于发 WM_CLOSE,走 `app.quit()` 正常收尾);
|
|
55
|
+
仍不领情才 `/T /F`
|
|
43
56
|
4. 用 `process.execPath` 重新拉起,Windows 上带 `--updated`(窗口会自己置前台)
|
|
57
|
+
5. 删掉跨进程锁,下一次重启才能正常排期
|
|
44
58
|
|
|
45
59
|
全程写 `~/.dsh/storages/plugin-autoupdate/restart.log`。
|
|
46
60
|
|
|
61
|
+
### 0.9.0 修的两条「重启之后 DSH 就没了」(实测复现 + 回归测试)
|
|
62
|
+
|
|
63
|
+
**故障一:助手是宿主的子进程,被自己的 `/T` 一起杀掉。**
|
|
64
|
+
|
|
65
|
+
0.8.0 用 `spawn(detached: true)` 拉助手,助手仍然是宿主的**子进程**。随后的
|
|
66
|
+
`taskkill /PID <宿主> /T /F` 会顺着 `ParentProcessId` 把助手一起结束 ——
|
|
67
|
+
日志停在「兜底结束旧宿主 pid=…」,`relaunched` 永远不出现。
|
|
68
|
+
实测确认:`taskkill /T` 连 detached 子进程一起杀。
|
|
69
|
+
|
|
70
|
+
现在两条路把助手送走:
|
|
71
|
+
|
|
72
|
+
| 通道 | 父进程 | 说明 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| **WMI** `Win32_Process.Create`(首选) | `WmiPrvSE.exe` | 压根不在调用树里,`/T` 走不到 |
|
|
75
|
+
| **两跳孤儿**(WMI 不可用时) | 已退出的第一跳 | 第一跳拉起第二跳后立刻 `exit(0)`,`/T` 顺树找不到它 |
|
|
76
|
+
|
|
77
|
+
**故障二:结束的是宿主(Host),不是 Electron main。**
|
|
78
|
+
|
|
79
|
+
插件跑在 Host 里,`process.pid` 是 Host 的 pid:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
DeepSeek Harness.exe (main, 13872)
|
|
83
|
+
├─ gpu / utility / renderer
|
|
84
|
+
└─ DeepSeek Harness.exe (Host, 8036) ← 插件在这里
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
只杀 Host,main 与 renderer 都还活着;新实例一启动就 `requestSingleInstanceLock()`
|
|
88
|
+
失败并**立刻退出** —— 用户看到「重启之后 DSH 不见了」。
|
|
89
|
+
|
|
90
|
+
现在 `lib/pidtree.js` 按「同一可执行文件往上爬到根」解析出 main,把它登记成目标 pid。
|
|
91
|
+
仅在 `process.versions.electron` 有值时启用(普通 node 环境里爬 `node.exe` 会爬到无关父进程);
|
|
92
|
+
解析不出就**只等不杀**,绝不回落到 `process.pid` / `process.ppid`。
|
|
93
|
+
|
|
94
|
+
**另外两个坑(都有回归测试):**
|
|
95
|
+
|
|
96
|
+
- **`ELECTRON_RUN_AS_NODE` 必须显式设。** 助手是用 `DeepSeek Harness.exe <helper.js>`
|
|
97
|
+
起的,不带这个变量 Electron 会把它当成一个完整 App 启动 —— 结果是又开一个 DSH 窗口、
|
|
98
|
+
助手永远不执行。`test/restart-electron-env.e2e.mjs` 用真 Electron 验证,
|
|
99
|
+
`…negative.mjs` 证明该断言不是空的。
|
|
100
|
+
- **WMI 不继承调用方环境。** 实测:父进程设的变量在 WMI 子进程里是空的。
|
|
101
|
+
所以 launcher 用 `.cmd` 逐行 `set` 白名单(`PATH` / `DSH_HOME` / `DSH_PROFILE` /
|
|
102
|
+
`DSH_DESKTOP_NODE_EXECUTABLE` / `TEMP` …),payload 也带一份;
|
|
103
|
+
白名单之外的变量(可能含令牌)不落盘。
|
|
104
|
+
|
|
105
|
+
**并发**:面板连点或自动更新撞车时,0.8.0 会拉起多个助手互相打架(日志里 9 秒内 3 条
|
|
106
|
+
`scheduled restart`)。现在有进程内冷却(60 秒)+ 跨进程锁文件,第二次请求直接返回
|
|
107
|
+
「已有一个重启在执行中,本次跳过」。
|
|
108
|
+
|
|
109
|
+
### 为什么绝不按映像名杀进程(0.8.0 修复的实机故障)
|
|
110
|
+
|
|
111
|
+
Electron 的 main / gpu-process / renderer / utility,以及以 `ELECTRON_RUN_AS_NODE`
|
|
112
|
+
跑起来的宿主,**映像名完全相同**(`DeepSeek Harness.exe`)。0.7.x 在「清掉同名残留进程」
|
|
113
|
+
这一步用 `tasklist /FI "IMAGENAME eq ..."` 取全部同名 pid 再逐个强制结束,实际后果是:
|
|
114
|
+
|
|
115
|
+
- 把自己的 renderer 一起打死 → `Desktop renderer exited: launch-failed` 崩溃日志
|
|
116
|
+
- 用户看到的是 **DSH 无预警消失、随后自动重开**,完全不像「更新完成后的正常重启」
|
|
117
|
+
|
|
118
|
+
0.8.0 起助手只认两个明确的 pid:**自己**(`process.pid`,永不动)与
|
|
119
|
+
**调用方登记的旧宿主**(`mainPid`)。这条边界由 `test/restart-safety.test.mjs`
|
|
120
|
+
以源码契约钉死(`tasklist` 不得出现 `IMAGENAME`、`taskkill` 必须带 `/PID` 且不得带 `/IM`)。
|
|
121
|
+
|
|
47
122
|
「只在没有会话正在跑的时候重启」默认开着:判定依据是最近 90 秒内有没有会话事件。
|
|
48
|
-
|
|
123
|
+
有会话时重启会被推迟,等空闲了再执行。**手动卸载与手动重启不受这条约束。**
|
|
124
|
+
|
|
49
125
|
|
|
50
126
|
## 状态文件
|
|
51
127
|
|
|
@@ -96,8 +172,49 @@
|
|
|
96
172
|
| POST | `/restart` | 立即重启 |
|
|
97
173
|
| GET | `/backups` | 列出清单备份 |
|
|
98
174
|
| POST | `/restore` | 用某份备份回滚清单 |
|
|
175
|
+
| GET | `/removable` | 哪些插件能卸(透传宿主 `removable` / `availability`) |
|
|
176
|
+
| POST | `/uninstall` | 卸载一个插件(body: `{ name, confirm: true }`) |
|
|
99
177
|
| GET | `/diagnose` | 看插件识别结果(排查「为什么某个包没被检查」) |
|
|
100
178
|
|
|
179
|
+
## 卸载插件(0.8.0 新增)
|
|
180
|
+
|
|
181
|
+
桌面 profile 下一个插件同时躺在**三处状态**里,漏掉任何一处都会留下
|
|
182
|
+
「卸了但行为还在」或「UI 里没了却仍在加载」的半成品:
|
|
183
|
+
|
|
184
|
+
1. `package.json` 的 `dependencies`
|
|
185
|
+
2. `dsh.profile.bundles`(不在这里就不会被加载)
|
|
186
|
+
3. `cordis.patch.yml` 里针对它的补丁行
|
|
187
|
+
|
|
188
|
+
所以卸载**只走宿主官方 `pluginManager.removeBundle(name)`**:它一次做完
|
|
189
|
+
「摘选 bundle → `pnpm remove` → 重新发布并 reload 加载树」。本插件绝不自己去改
|
|
190
|
+
这三个文件(自己改最容易漏,而且和宿主内存状态不同步)。
|
|
191
|
+
|
|
192
|
+
### 什么能卸、什么不能
|
|
193
|
+
|
|
194
|
+
| 情形 | 结果 |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `availability === 'profile'` 且 `removable === true` | 可卸 |
|
|
197
|
+
| `availability === 'installation'`(随 DSH 安装提供) | 拒绝,说明原因 |
|
|
198
|
+
| `removable !== true`(正在运行 / 宿主只读) | 拒绝,回显宿主的 `readOnlyReason` |
|
|
199
|
+
| 本插件自身 `ygc-dsh-plugin-autoupdate` | 拒绝,避免把自动更新器自己拆了 |
|
|
200
|
+
| `@deepseek-ai/dsh-plugin-manager` | 拒绝,卸掉后整个插件体系都加载不了 |
|
|
201
|
+
| 宿主没提供 `pluginManager` | 拒绝,明确告知改用 DSH 自带插件管理 |
|
|
202
|
+
|
|
203
|
+
### 三条硬约束
|
|
204
|
+
|
|
205
|
+
1. **必须显式确认**:`POST /uninstall` 没有 `confirm: true` 一律不执行;
|
|
206
|
+
面板上的按钮会先弹二次确认,取消就什么都不做。
|
|
207
|
+
2. **不可卸载时按钮置灰并说明原因**:信息未知(拿不到宿主清单)时也置灰——
|
|
208
|
+
宁可点不了,也不让用户点了才发现不行。
|
|
209
|
+
3. **卸载后自动重启生效**:模块已经加载进宿主内存,不重启不会真的消失。
|
|
210
|
+
走的是与自动更新**同一条**重启通道(优雅优先),不另写一套杀进程逻辑。
|
|
211
|
+
|
|
212
|
+
### 卸载 → 重启用哪条路
|
|
213
|
+
|
|
214
|
+
与「自动重启怎么做的」完全一致:优先 `desktopRuntime.requestRestart()`,
|
|
215
|
+
拿不到才用分离助手。卸载时**不受「只在空闲时重启」约束**——
|
|
216
|
+
这是用户当前主动发起的操作,不会打断别人的长任务还装作没发生。
|
|
217
|
+
|
|
101
218
|
## 插件共享(特征文件夹)
|
|
102
219
|
|
|
103
220
|
把「我这台机器装了什么插件」沉淀成一份可以给别人看的东西,互相补全。
|
|
@@ -228,26 +345,53 @@ $node = "C:\DeepSeekHarness\resources\runtime\primary-runtime\dependencies\node\
|
|
|
228
345
|
& $node "C:\DSH-Work\plugin-autoupdate\pkg\test\all.mjs"
|
|
229
346
|
```
|
|
230
347
|
|
|
231
|
-
|
|
348
|
+
十六个套件:
|
|
232
349
|
|
|
233
350
|
| 套件 | 覆盖 |
|
|
234
351
|
|---|---|
|
|
235
352
|
| `selftest.mjs` | 版本比较(含 prerelease)、状态规范化夹紧、延迟判定、registry packument 解析、本机 profile 识别 |
|
|
236
353
|
| `workspace-merge.test.mjs` | `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 合并(同名并成一行、不重复、CRLF 保持、段落连续) |
|
|
237
354
|
| `api-smoke.test.mjs` | 用假 ctx 真加载 Host 半,走完全部 API 路由;状态写在临时 HOME,不碰真实 profile |
|
|
355
|
+
| `snapshot-regression.test.mjs` | 面板快照结构回归 |
|
|
356
|
+
| `update-guard.test.mjs` | 只向上更新、来源判定、发布年龄门槛 |
|
|
357
|
+
| `git-source.test.mjs` | Git 来源的 commit 比较与安装 spec |
|
|
238
358
|
| `machine.test.mjs` | 本机标识:继承会话同步的 machineId、固定后不漂移、改名不换特征值、卸载会话同步后旧值仍有效、写边界(别人的格 / `../` 逃逸 / 前缀相近) |
|
|
239
359
|
| `share.test.mjs` | 特征文件夹:清单分类(registry 只写名 / 本地打副本 / 宿主自带不写)、tgz 结构、同步幂等与副本复用、他人清单合并去重、安装路径选择、特征名防上跳 |
|
|
360
|
+
| `schedule.test.mjs` | 定时排期:更新与共享同步各自独立开关 |
|
|
361
|
+
| `restart-safety.test.mjs` | **重启安全契约**(源码级):不得按映像名杀进程、必须排除自身、必须先等再兜底、助手须由 WMI/两跳送出进程树、须先礼后兵、须有冷却与锁、launcher 须设 `ELECTRON_RUN_AS_NODE` |
|
|
362
|
+
| `pidtree.test.mjs` | **进程树解析**(合成进程表):Host 爬到 main、renderer 爬到同一个 main、起点缺失/父进程缺失/链过深时的边界、只在 Electron 里才爬 |
|
|
363
|
+
| `restart-helper-behavior.test.mjs` | **助手行为**(真子进程):旧 main 自退时零强制动作、不退出时先请求正常退出再强杀、只针对登记的那个 pid、payload 读后即删 |
|
|
364
|
+
| `restart-relaunch.e2e.mjs` | **重启链路**(真进程树):助手父进程不是调用方、整树 `taskkill /T /F` 之后助手仍活着并完成 `relaunched` |
|
|
365
|
+
| `restart-env.e2e.mjs` | **环境传递**:`DSH_PROFILE` / `DSH_HOME` / `PATH` 传到新实例、白名单外的变量不落盘、新实例不带 `ELECTRON_RUN_AS_NODE` |
|
|
366
|
+
| `restart-electron-env.e2e.mjs` | **Electron 助手模式**(真 Electron exe):launcher 能真的让 Electron 以 Node 模式跑脚本 |
|
|
367
|
+
| `restart-electron-env.negative.mjs` | **负向对照**:故意不带 `ELECTRON_RUN_AS_NODE` 时脚本不执行,证明上一条断言不是空的 |
|
|
368
|
+
| `uninstall.test.mjs` | **卸载契约**:未确认不出手、`installation`/不可移除的拒绝、拒绝卸自己与管理器、走 `removeBundle`、失败写 `uninstall-failed`、成功后请求重启;外加前端按钮 / 二次确认 / `canUninstall` 契约 |
|
|
240
369
|
| `client-smoke.test.mjs` | 在 Node 里用假 `document` / React 求值 `client.js`,验证插槽注册、locale 注册、面板首渲染 |
|
|
241
370
|
|
|
371
|
+
真机取证脚本(不属于 `all.mjs`,需要手工跑):
|
|
372
|
+
|
|
373
|
+
```powershell
|
|
374
|
+
# 在真宿主进程树里跑一次解析,确认能指出 Electron main
|
|
375
|
+
$env:ELECTRON_RUN_AS_NODE = '1'
|
|
376
|
+
& "C:\DeepSeekHarness\DeepSeek Harness.exe" "C:\DSH-Work\plugin-autoupdate\pkg\test\_live-pidtree.mjs"
|
|
377
|
+
```
|
|
378
|
+
|
|
242
379
|
## 边界
|
|
243
380
|
|
|
244
|
-
-
|
|
381
|
+
- 只**更新**已装插件,不安装新插件;**卸载仅限 profile 自装且宿主判定 removable 的包**(见「卸载插件」)
|
|
245
382
|
- 只在 `registry` 来源上工作;`local` / `git` / `installation` 来源只显示、不处理
|
|
246
383
|
- 更新失败只记历史,不自动回滚(避免把用户自己手改的清单吞掉);要回滚用 `/restore`
|
|
247
384
|
- DSH 关闭期间不检查(进程内定时器的固有限制);重开后会立刻按上游发布时间重新判定
|
|
248
385
|
- 共享只**同步清单与副本**,不自动安装别人的插件;安装必须人工点选
|
|
249
386
|
- 只写 `插件库\<本机标识>\` 那一格,别人的格子只读(写入口有守卫)
|
|
250
387
|
- 不在插件库根下留任何公共文件或公共目录
|
|
388
|
+
- **重启只认显式 pid**:任何「按映像名清场」的写法都是回归,`restart-safety.test.mjs` 会红
|
|
389
|
+
- **重启助手必须活在进程树之外**:`spawn(detached)` 逃不掉 `taskkill /T`(实测),
|
|
390
|
+
任何退回「直接 spawn 助手」的改动都会让 0.9.0 的故障复活
|
|
391
|
+
- **要结束的是 Electron main**:插件里的 `process.pid` 是 Host,
|
|
392
|
+
只杀 Host 等于没重启。目标 pid 只能来自 `lib/pidtree.js` 的解析结果或调用方显式登记
|
|
393
|
+
- **没有可信 pid 就只等不杀**:绝不回落到 `process.pid` / `process.ppid`
|
|
394
|
+
- **launcher 必须设 `ELECTRON_RUN_AS_NODE=1`**:否则 Electron 把助手脚本当 App 启动
|
|
251
395
|
|
|
252
396
|
## 踩过的坑
|
|
253
397
|
|
package/client.js
CHANGED
|
@@ -26,6 +26,8 @@ const CSS = `
|
|
|
26
26
|
.pau-btn:disabled{opacity:.45;cursor:not-allowed}
|
|
27
27
|
.pau-btn-primary{background:var(--dsw-alias-brand-primary,#2563eb);border-color:transparent;color:#fff}
|
|
28
28
|
.pau-btn-sm{padding:3px 10px;font-size:12px}
|
|
29
|
+
.pau-btn-danger{border-color:var(--dsw-alias-border-danger,#e5484d);color:var(--dsw-alias-label-danger,#c62a2f)}
|
|
30
|
+
.pau-btn-danger:hover:not(:disabled){background:var(--dsw-alias-bg-danger,#fef2f2)}
|
|
29
31
|
.pau-input{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);min-width:0;box-sizing:border-box}
|
|
30
32
|
.pau-input-num{width:76px}
|
|
31
33
|
.pau-select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111)}
|
|
@@ -467,6 +469,45 @@ function makePanel(React) {
|
|
|
467
469
|
},
|
|
468
470
|
'刷新',
|
|
469
471
|
),
|
|
472
|
+
h(
|
|
473
|
+
'button',
|
|
474
|
+
{
|
|
475
|
+
className: 'pau-btn pau-btn-sm pau-btn-danger',
|
|
476
|
+
// 只有宿主明确说 removable 才让点:信息未知时宁可置灰,也不让用户点了才发现不行
|
|
477
|
+
disabled: !!busy || !plugin.canUninstall,
|
|
478
|
+
title: plugin.canUninstall
|
|
479
|
+
? `从 profile 卸载 ${plugin.name}(会先二次确认;卸载后自动重启生效)`
|
|
480
|
+
: plugin.uninstallBlockedReason || '不能卸载',
|
|
481
|
+
onClick: () => {
|
|
482
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
483
|
+
const ok = window.confirm(
|
|
484
|
+
`确定要卸载 ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''} 吗?\n\n` +
|
|
485
|
+
'这会把它从 profile 的依赖与插件清单里移除,并自动重启 DSH 才生效。\n' +
|
|
486
|
+
'(卸载前会自动备份 profile 的 package.json / pnpm-lock.yaml / pnpm-workspace.yaml)',
|
|
487
|
+
);
|
|
488
|
+
if (!ok) {
|
|
489
|
+
setNote('已取消卸载');
|
|
490
|
+
return Promise.resolve(null);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return run(`uninstall:${plugin.name}`, async () => {
|
|
494
|
+
const next = await call('/uninstall', { name: plugin.name, confirm: true });
|
|
495
|
+
const result = next.result || {};
|
|
496
|
+
if (result.ok) {
|
|
497
|
+
setNote(
|
|
498
|
+
result.restart && result.restart.ok
|
|
499
|
+
? `${plugin.name} 已卸载,正在重启生效`
|
|
500
|
+
: `${plugin.name} 已卸载;请手动完全退出 DSH 再重开`,
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
setNote(result.message || '卸载未完成');
|
|
504
|
+
}
|
|
505
|
+
return next;
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
busy === `uninstall:${plugin.name}` ? '卸载中…' : '卸载',
|
|
510
|
+
),
|
|
470
511
|
);
|
|
471
512
|
};
|
|
472
513
|
|
package/index.js
CHANGED
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
import { normalizeRegistry, probePackage, queryLatest } from './lib/registry.js';
|
|
35
35
|
import { parseLockGitCommit, queryCommitMessage, queryGitHead, queryLatestTag } from './lib/gitref.js';
|
|
36
36
|
import { backupProfile, installSpec, installVersion } from './lib/updater.js';
|
|
37
|
-
import { scheduleRestart } from './lib/restart.js';
|
|
37
|
+
import { gracefulHost, scheduleRestart } from './lib/restart.js';
|
|
38
|
+
import { createUninstall } from './lib/uninstall.js';
|
|
38
39
|
import { createApi } from './lib/api.js';
|
|
39
40
|
import { installSharedPlugin, libraryRoot, shareSnapshot, syncMyFeature, writeAggregate } from './lib/share.js';
|
|
40
41
|
import { resolveIdentity } from './lib/machine.js';
|
|
@@ -556,7 +557,22 @@ export function apply(ctx, config = {}) {
|
|
|
556
557
|
}
|
|
557
558
|
};
|
|
558
559
|
|
|
559
|
-
/**
|
|
560
|
+
/**
|
|
561
|
+
* 请求重启(受「只在空闲时重启」约束)。
|
|
562
|
+
*
|
|
563
|
+
* 两条通道,优先官方:
|
|
564
|
+
* 1. graceful:宿主若暴露 `desktopRuntime.requestRestart()`,走它 ——
|
|
565
|
+
* 桌面 shell 自己 relaunch 并无确认退出,把这次退出当作预期内的退出,
|
|
566
|
+
* **不会弹「启动失败」恢复框、不写 crash report**。
|
|
567
|
+
* 2. helper:拿不到官方通道才退回「把助手送到进程树之外 → 等旧 main 退出 →
|
|
568
|
+
* 先请它正常退、必要时定向强杀 → 拉起新实例」。
|
|
569
|
+
*
|
|
570
|
+
* 0.9.0 修的两条真故障(见 lib/restart.js 顶部铁律):
|
|
571
|
+
* - 助手原来是被 spawn 出来的**子进程**,`taskkill /T` 会连它一起杀掉,
|
|
572
|
+
* 日志停在「兜底结束旧宿主」就没了下文(DSH 重启后消失);
|
|
573
|
+
* - 原来结束的是宿主(Host)而不是 Electron main,main 还活着,
|
|
574
|
+
* 新实例一启动就被单实例锁挡回去。
|
|
575
|
+
*/
|
|
560
576
|
const requestRestart = (why, stateMaybe) => {
|
|
561
577
|
const state = stateMaybe || loadState();
|
|
562
578
|
if (state.restartOnlyWhenIdle && isBusy()) {
|
|
@@ -567,11 +583,13 @@ export function apply(ctx, config = {}) {
|
|
|
567
583
|
return { ok: false, deferred: true, message: '有会话正在跑,等空闲后重启' };
|
|
568
584
|
}
|
|
569
585
|
restartPendingReason = '';
|
|
570
|
-
const answer =
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
586
|
+
const answer =
|
|
587
|
+
gracefulHost(ctx) ||
|
|
588
|
+
scheduleRestart({
|
|
589
|
+
delaySeconds: state.restartDelaySeconds,
|
|
590
|
+
omitArgs: true,
|
|
591
|
+
reason: why,
|
|
592
|
+
});
|
|
575
593
|
const next = loadState();
|
|
576
594
|
pushHistory(next, {
|
|
577
595
|
kind: answer.ok ? 'restart' : 'restart-failed',
|
|
@@ -736,6 +754,46 @@ export function apply(ctx, config = {}) {
|
|
|
736
754
|
runShareSync,
|
|
737
755
|
shareState,
|
|
738
756
|
installFromShare,
|
|
757
|
+
// 卸载:走宿主官方 pluginManager.removeBundle,本插件不自己改清单/包目录
|
|
758
|
+
uninstall: createUninstall({
|
|
759
|
+
getManager: () => {
|
|
760
|
+
try {
|
|
761
|
+
return ctx.get('pluginManager');
|
|
762
|
+
} catch {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
requestRestart,
|
|
767
|
+
log,
|
|
768
|
+
}),
|
|
769
|
+
// 面板需要知道「哪些能卸」,直接透传宿主清单里的 removable/availability
|
|
770
|
+
listRemovable: async () => {
|
|
771
|
+
let manager;
|
|
772
|
+
try {
|
|
773
|
+
manager = ctx.get('pluginManager');
|
|
774
|
+
} catch {
|
|
775
|
+
manager = undefined;
|
|
776
|
+
}
|
|
777
|
+
if (!manager || typeof manager.listBundles !== 'function') {
|
|
778
|
+
return { available: false, bundles: [] };
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const bundles = await manager.listBundles();
|
|
782
|
+
return {
|
|
783
|
+
available: true,
|
|
784
|
+
bundles: (Array.isArray(bundles) ? bundles : []).map((item) => ({
|
|
785
|
+
name: item?.name,
|
|
786
|
+
version: item?.version || null,
|
|
787
|
+
enabled: item?.enabled === true,
|
|
788
|
+
availability: item?.availability || null,
|
|
789
|
+
removable: item?.removable === true,
|
|
790
|
+
readOnlyReason: item?.readOnlyReason || null,
|
|
791
|
+
})),
|
|
792
|
+
};
|
|
793
|
+
} catch (error) {
|
|
794
|
+
return { available: false, bundles: [], error: String(error?.message || error) };
|
|
795
|
+
}
|
|
796
|
+
},
|
|
739
797
|
});
|
|
740
798
|
|
|
741
799
|
ctx.effect(() =>
|
package/lib/api.js
CHANGED
|
@@ -48,6 +48,9 @@ async function readJson(req) {
|
|
|
48
48
|
function snapshot(deps) {
|
|
49
49
|
const state = loadState();
|
|
50
50
|
const cached = state.lastCheck || {};
|
|
51
|
+
// 卸载能力:宿主清单里的 removable/availability 决定按钮是否可点。
|
|
52
|
+
// 同步预取一次(快照本来就是 async 调用方在用),拿不到就整体置灰。
|
|
53
|
+
const removable = deps._removableCache || null;
|
|
51
54
|
const plugins = deps.listPlugins().map((plugin) => {
|
|
52
55
|
const policy = policyFor(state, plugin.name);
|
|
53
56
|
const pending = state.pending[plugin.name];
|
|
@@ -60,6 +63,8 @@ function snapshot(deps) {
|
|
|
60
63
|
const active = newer ? pending : undefined;
|
|
61
64
|
// git 来源没有版本号可比,可更新性直接看检查阶段算出的 updateAvailable
|
|
62
65
|
const gitNewer = plugin.kind === 'git' ? Boolean(seen && seen.updateAvailable) : false;
|
|
66
|
+
// 可卸载性:默认不可卸(信息未知时宁可置灰,也不让用户点了才发现不行)
|
|
67
|
+
const row = removable ? removable.get(plugin.name) : undefined;
|
|
63
68
|
return {
|
|
64
69
|
...plugin,
|
|
65
70
|
policy: policy.policy,
|
|
@@ -73,6 +78,17 @@ function snapshot(deps) {
|
|
|
73
78
|
updateAvailable: plugin.kind === 'git' ? gitNewer : newer,
|
|
74
79
|
waitMs: active ? delayRemainingMs(state, plugin.name, active.firstSeenAt) : 0,
|
|
75
80
|
waitText: active ? humanHours(delayRemainingMs(state, plugin.name, active.firstSeenAt)) : '',
|
|
81
|
+
// 面板据此决定「卸载」按钮是否可点,以及鼠标悬停时解释为什么不行
|
|
82
|
+
canUninstall: Boolean(row && row.removable),
|
|
83
|
+
uninstallBlockedReason: row
|
|
84
|
+
? row.removable
|
|
85
|
+
? ''
|
|
86
|
+
: row.availability === 'installation'
|
|
87
|
+
? '随 DSH 安装提供,不能单独卸载'
|
|
88
|
+
: row.readOnlyReason
|
|
89
|
+
? `不可移除(${row.readOnlyReason})`
|
|
90
|
+
: '宿主判定为不可移除'
|
|
91
|
+
: '宿主未提供插件清单,无法卸载',
|
|
76
92
|
};
|
|
77
93
|
});
|
|
78
94
|
|
|
@@ -351,6 +367,26 @@ export function createApi(deps) {
|
|
|
351
367
|
json(res, 200, { ...snapshot(deps), updated, failed, restart });
|
|
352
368
|
},
|
|
353
369
|
|
|
370
|
+
/**
|
|
371
|
+
* 卸载一个插件。
|
|
372
|
+
*
|
|
373
|
+
* body: `{ name, confirm: true }` —— 没有 confirm 一律拒绝执行。
|
|
374
|
+
* 只卸 profile 自装的、宿主判定 removable 的包;走宿主
|
|
375
|
+
* `pluginManager.removeBundle`,本插件不自己改 package.json / node_modules。
|
|
376
|
+
* 成功后自动请求一次重启(模块已加载进内存,不重启不会真的消失)。
|
|
377
|
+
*/
|
|
378
|
+
'POST /uninstall': async (req, res) => {
|
|
379
|
+
const body = await readJson(req);
|
|
380
|
+
const result = await deps.uninstall({ name: body.name, confirm: body.confirm === true, reason: '手动' });
|
|
381
|
+
json(res, result.ok ? 200 : 400, { ...snapshot(deps), result });
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/** 哪些插件能卸:透传宿主清单的 removable / availability,供面板置灰与提示。 */
|
|
385
|
+
'GET /removable': async (req, res) => {
|
|
386
|
+
const answer = await deps.listRemovable();
|
|
387
|
+
json(res, 200, { ok: true, ...answer });
|
|
388
|
+
},
|
|
389
|
+
|
|
354
390
|
'POST /restart': async (req, res) => {
|
|
355
391
|
const state = loadState();
|
|
356
392
|
const answer = deps.requestRestart('手动重启', { ...state, restartOnlyWhenIdle: false });
|
package/lib/core.js
CHANGED
|
@@ -14,7 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
|
|
16
16
|
export const STATE_DIR = path.join(os.homedir(), '.dsh', 'storages');
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 状态文件位置。允许用环境变量覆盖:
|
|
19
|
+
* 探针/端到端测试会走真实路由,绝不能把测试记录写进用户真实配置。
|
|
20
|
+
*/
|
|
21
|
+
export const STATE_FILE =
|
|
22
|
+
process.env.DSH_PLUGIN_AUTOUPDATE_STATE_FILE || path.join(STATE_DIR, 'plugin-autoupdate.json');
|
|
18
23
|
|
|
19
24
|
/** 默认检查间隔(分钟)。 */
|
|
20
25
|
export const DEFAULT_INTERVAL_MINUTES = 360;
|
package/lib/pidtree.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程树:从「宿主进程」往上找到真正的 **Electron 主进程**。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么需要这个(2026-11-04 二次排障)──────────────────────────────
|
|
5
|
+
* 本插件跑在 **Host** 里,Host 是 Electron main 以 `ELECTRON_RUN_AS_NODE=1`
|
|
6
|
+
* 启动的子进程:
|
|
7
|
+
*
|
|
8
|
+
* DeepSeek Harness.exe (main, 例如 13872)
|
|
9
|
+
* ├─ gpu-process / utility / renderer
|
|
10
|
+
* └─ DeepSeek Harness.exe --expose-internals … (Host, 例如 8036)
|
|
11
|
+
* └─ dsh-subprocess-local runner…
|
|
12
|
+
*
|
|
13
|
+
* `lib/restart.js` 里 `process.pid` 拿到的是 **Host** 的 pid,不是 main 的。
|
|
14
|
+
* 0.8.0 只结束 Host → renderer / main 都还活着 → 新实例起来时
|
|
15
|
+
* `requestSingleInstanceLock()` 失败并立刻退出,用户看到的是
|
|
16
|
+
* 「重启后 DSH 不见了 / 没起来」,而不是重启。
|
|
17
|
+
*
|
|
18
|
+
* main 没有现成的 IPC 能让插件问出来,所以只能按「进程树 + 同一可执行文件」
|
|
19
|
+
* 往上找:一路向上,直到父进程的可执行文件与本进程**不同**为止,
|
|
20
|
+
* 那一层就是这棵树的根,也就是 Electron main。
|
|
21
|
+
*
|
|
22
|
+
* ── 为什么必须限制跳数 ────────────────────────────────────────────────
|
|
23
|
+
* 开发/CLI 场景下可执行文件是 `node.exe`,整条链(bash → node → …)同名,
|
|
24
|
+
* 无上限地往上爬会爬到宿主的父进程甚至用户 shell。
|
|
25
|
+
* 因此:只对**桌面宿主 exe**启用本函数(调用方判定),并硬性限制 maxHops。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from 'node:child_process';
|
|
29
|
+
|
|
30
|
+
/** 读取一次全量进程表:pid -> { ppid, exe }。仅 Windows 可用。 */
|
|
31
|
+
function readProcessMap() {
|
|
32
|
+
if (process.platform !== 'win32') return undefined;
|
|
33
|
+
// 只取三个字段;ExecutablePath 对受保护进程可能为空,按「未知」处理
|
|
34
|
+
const script = [
|
|
35
|
+
"$ErrorActionPreference='Stop';",
|
|
36
|
+
'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,ExecutablePath |',
|
|
37
|
+
"ForEach-Object { '' + $_.ProcessId + '|' + $_.ParentProcessId + '|' + [string]$_.ExecutablePath }",
|
|
38
|
+
].join(' ');
|
|
39
|
+
let out;
|
|
40
|
+
try {
|
|
41
|
+
out = execFileSync(
|
|
42
|
+
'powershell',
|
|
43
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
44
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 8 * 1024 * 1024 },
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const line of String(out).split(/\r?\n/)) {
|
|
51
|
+
const text = line.trim();
|
|
52
|
+
if (!text) continue;
|
|
53
|
+
const [pidText, ppidText, exeText] = text.split('|');
|
|
54
|
+
const pid = Number(pidText);
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
56
|
+
map.set(pid, {
|
|
57
|
+
ppid: Number(ppidText) || 0,
|
|
58
|
+
exe: (exeText || '').trim(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return map.size ? map : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const norm = (value) => String(value || '').replace(/\//g, '\\').toLowerCase();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 找到 `startPid` 所在「同一可执行文件」子树的根进程 pid。
|
|
68
|
+
*
|
|
69
|
+
* 边界处理(这几种情况含义不同):
|
|
70
|
+
* - 起点自己不在表里 → 无法判定,返回 undefined,让调用方回落;
|
|
71
|
+
* - 父进程不在表里(已退出 / 表不全)→ 当前层就是这棵树的根,正常返回;
|
|
72
|
+
* - 父进程存在但**可执行文件不同** → 当前层就是根,正常返回;
|
|
73
|
+
* - 爬到 maxHops 还没到底 → 判不出来,返回 undefined。
|
|
74
|
+
*
|
|
75
|
+
* 实测:本机 Electron main 的父进程(explorer 那层)经常已经不在表里,
|
|
76
|
+
* 上面第 2 条是**正常路径**,不能当成失败。
|
|
77
|
+
*
|
|
78
|
+
* @param {number} startPid 起点(宿主自己的 pid)
|
|
79
|
+
* @param {string} exePath 期望的可执行文件(宿主 exe,`process.execPath`)
|
|
80
|
+
* @param {{maxHops?: number, map?: Map<number, {ppid: number, exe: string}>}} [options]
|
|
81
|
+
* @returns {{root: number, hops: number, chain: number[]}|undefined} 判不出来就返回 undefined
|
|
82
|
+
*/
|
|
83
|
+
export function rootSameExePid(startPid, exePath, options = {}) {
|
|
84
|
+
const start = Number(startPid);
|
|
85
|
+
const want = norm(exePath);
|
|
86
|
+
if (!start || !want) return undefined;
|
|
87
|
+
const map = options.map || readProcessMap();
|
|
88
|
+
if (!map) return undefined;
|
|
89
|
+
const maxHops = Math.max(1, Number(options.maxHops) || 8);
|
|
90
|
+
if (!map.has(start)) return undefined;
|
|
91
|
+
|
|
92
|
+
const chain = [start];
|
|
93
|
+
let current = start;
|
|
94
|
+
let hops = 0;
|
|
95
|
+
while (hops < maxHops) {
|
|
96
|
+
const entry = map.get(current);
|
|
97
|
+
if (!entry) break;
|
|
98
|
+
const parent = map.get(entry.ppid);
|
|
99
|
+
// 父进程查不到(已退出或表不全)或者可执行文件不同 —— 当前这一层就是根
|
|
100
|
+
if (!parent || norm(parent.exe) !== want) return { root: current, hops, chain };
|
|
101
|
+
current = entry.ppid;
|
|
102
|
+
chain.push(current);
|
|
103
|
+
hops += 1;
|
|
104
|
+
}
|
|
105
|
+
// 爬到上限还没找到底:这条链不干净,不猜
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 仅供测试/诊断:把进程表转成普通对象,避免把 Map 直接抛给日志。 */
|
|
110
|
+
export function describePid(pid, options = {}) {
|
|
111
|
+
const map = options.map || readProcessMap();
|
|
112
|
+
if (!map) return undefined;
|
|
113
|
+
const entry = map.get(Number(pid));
|
|
114
|
+
return entry ? { pid: Number(pid), ...entry } : undefined;
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两跳孤儿的**第一跳**:拉起真正的重启助手,然后立刻退出。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么需要它(2026-11-04 实测)────────────────────────────────────
|
|
5
|
+
* `spawn(detached:true)` **不能**让子进程逃出 `taskkill /T`:
|
|
6
|
+
* 实测 `/T` 顺着 ParentProcessId 把 detached 子进程一起杀了
|
|
7
|
+
* (A 拉起 B,`taskkill /PID A /T /F` 之后 B 也没了)。
|
|
8
|
+
*
|
|
9
|
+
* 但「父进程已经退出的孤儿」逃得掉:A 拉起 B 后立刻 exit,B 的父进程
|
|
10
|
+
* 变成已死的 A,`/T` 从宿主往下遍历根本碰不到 B(实测 B 存活)。
|
|
11
|
+
*
|
|
12
|
+
* 所以本文件只干一件事:把第二跳(restart-helper.js)拉起来,然后 die。
|
|
13
|
+
* 首选通道是 WMI(父进程是 WmiPrvSE,压根不在这棵树里),
|
|
14
|
+
* 本文件只在 WMI 不可用时作为备用。
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const payloadFile = process.argv[2];
|
|
20
|
+
if (!payloadFile) process.exit(1);
|
|
21
|
+
|
|
22
|
+
const HELPER = fileURLToPath(new URL('./restart-helper.js', import.meta.url));
|
|
23
|
+
const exe = process.execPath;
|
|
24
|
+
|
|
25
|
+
const env = { ...process.env };
|
|
26
|
+
// 第二跳照样是「Electron 以 Node 模式跑脚本」
|
|
27
|
+
env.ELECTRON_RUN_AS_NODE = '1';
|
|
28
|
+
delete env.DSH_PERMISSION_MODE;
|
|
29
|
+
delete env.DSH_SESSION_ID;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(exe, [HELPER, payloadFile], {
|
|
33
|
+
detached: true,
|
|
34
|
+
stdio: 'ignore',
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
env,
|
|
37
|
+
shell: false,
|
|
38
|
+
});
|
|
39
|
+
child.unref();
|
|
40
|
+
} catch {
|
|
41
|
+
/* 拉不起来就让第二跳不存在;日志会显示没有 helper start */
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 立刻退出:这一退,第二跳就成了孤儿,`taskkill /T` 再也走不到它
|
|
45
|
+
process.exit(0);
|