@nimoxie/dsh-ui-tweaks 0.1.4 → 0.1.5
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/DEVELOPMENT.md +19 -10
- package/LICENSE +21 -21
- package/README.md +5 -2
- package/cordis.patch.yml +7 -7
- package/lib/client.js +346 -43
- package/lib/index.js +317 -99
- package/package.json +1 -1
package/DEVELOPMENT.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 骨架(与 dsh-model-organizer 同构)
|
|
6
6
|
|
|
7
|
-
- `lib/index.js`:host
|
|
7
|
+
- `lib/index.js`:host 半区,挂两条回环路由(`GET /api/ui-tweaks/local-plugins` 本地包识别、`POST /api/ui-tweaks/normalize-excludes` 豁免表归并),并在加载时静默归并一次;细节见「更新检查」。
|
|
8
8
|
- `lib/client.js`:浏览器半区,`module.exports = { name, apply, inject }`;`apply(ctx)` 由客户端 cordis 上下文调用。
|
|
9
9
|
- `cordis.patch.yml`:`- insert: [{id: ui-tweaks, name: '@nimoxie/dsh-ui-tweaks'}]`。**profile 必须声明 `dsh.bundle.patch`,否则 dsh 启动即报 declares no dsh.bundle**;**insert.name 必须写全 scope**(loader 按它在 node_modules 解析包)。
|
|
10
10
|
- `package.json`:`exports["./client"]` 指向 client bundle;`dsh.client.platform: "web"`;inject 只需 `@deepseek-ai/dsh-api-remotes`(`remote.*` 服务由它提供)。`react` / `react-dom` 等是 shell 内置(PLATFORM_MODULES),不用声明。
|
|
@@ -31,31 +31,35 @@
|
|
|
31
31
|
|
|
32
32
|
## 更新检查(模块 3)
|
|
33
33
|
|
|
34
|
-
###
|
|
34
|
+
### 关键经验:pnpm 的 minimumReleaseAge 是主要的静止源(0.1.5 起分成两条路径看)
|
|
35
35
|
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
36
|
+
- **`minimumReleaseAge` 有两条独立的作用路径,别混为一谈**(0.1.5 修订;0.1.4 这段只写对了一半):
|
|
37
|
+
1. **门禁(在解析之前)**:`pnpm install/add` 一上来就校验**磁盘上现有的** lockfile,任一 entry 的发布时间落在窗口内 → **整体中止整个 profile 的安装**并报 `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`(exit 1)。这一步与"你要装什么"无关,**写精确版本号也救不了**。
|
|
38
|
+
2. **解析(在门禁之后)**:候选不合法时 pnpm 会挑「最新合法版本」(于是装上后版本没变 → 面板判「暂缓」);但**显式写 `name@版本`** 时它会打印 `Added 1 entry to minimumReleaseAgeExclude in pnpm-workspace.yaml` 并照常安装——这条仍然成立,只是它管不了第 1 条。
|
|
39
|
+
- **豁免表的坑(0.1.5 实测)**:`minimumReleaseAgeExclude` 里同一个包名出现**多条**时**只有文件中靠前的那条生效**(不是并集)。同一份 lockfile/yaml 只改顺序:`[pkg@0.69.0, pkg@0.61.0]` 通过、`[pkg@0.61.0, pkg@0.69.0]` 失败。于是陈旧条目**静默遮蔽**新条目,此后每次安装都被门禁拦下;而一次成功的未成熟安装又会把新条目**追加在末尾**,让旧条重新变成靠前那条 → 下一次安装立刻失败。**这就是 0.1.5 之前用户侧「更新某插件 → 报一长串 pnpm 错误 → 之后所有插件都装不上」的真因。**
|
|
40
|
+
- **对策(0.1.5)**:host 半区新增归并器 `normalizeReleaseAgeExcludes()`(`lib/index.js`),把该表收敛成「每包一条」——同包多条只留"与 lockfile 锁定版本一致"的那条(否则留靠前那条);受管包名(profile `dependencies` 里的)唯一条且版本 ≠ 锁定版本 → 删除;非受管且不重复的条目不动。结果恒为原条目的**子集**(绝不比现状更宽松)。触发:插件加载时静默一次 + `POST /api/ui-tweaks/normalize-excludes`(client 在安装落地成功后与「重新检查」时 fire-and-forget 调用)。安全约束:只改 `minimumReleaseAgeExclude` 这一段、其余字节逐字节保留;写前留 `pnpm-workspace.yaml.bak-<时间戳>`;同目录临时文件 + `renameSync` 原子替换;写后重读校验;无变化不写盘;幂等;异常一律不外抛。
|
|
41
|
+
- **客户端「冷静期中」(cooling)**:两条判定路——① 预判:拉该包**全量 packument** 读 `time[版本]`(`/<pkg>/latest` 与 abbreviated packument 都**没有** `time`,实测),`发布时间 + 窗口 > 现在` 即标 cooling;② 反应式:门禁报错里同时有 `published at <ISO>` 与 `cutoff <ISO>`,于是 `窗口 = now − cutoff`、`成熟时刻 = 发布 + 窗口`(前提是刚失败就解析,秒级误差)。反推窗口只接受 (0, 30 天],越界退回会话默认值(真实值会从首次报错学到)。cooling 标签**不可点击安装**(点它必然撞门禁、还可能连累整个 profile),只切换详情;`enqueue` 里还有第二道闸。
|
|
42
|
+
- **本机实测环境**:dsh 用的是**自带的 pnpm 11.7.0**(`<DSH_HOME>/dsh-runtimes/dsh-primary-runtime/dependencies/pnpm`),**不是** PATH 上那个(本机 PATH 是 11.21.0)。排查这类问题必须用前者复现,否则结论会反过来。
|
|
43
|
+
- 参考实现:社区分叉版 `@linxin666/dsh-client-ui-plugin-manager` 的 update handler 也是先 `fetchManifest` 拿 registry 的 `version`,再 `installBundle(name@version)`——它「能更新」不是因为绕过了门禁,而是因为它用的是精确版本(只绕开第 2 条路径)。
|
|
40
44
|
|
|
41
45
|
### 本地包识别 = host 路由为主,404 签名兜底
|
|
42
46
|
|
|
43
|
-
- `listBundles()` 不透传安装来源(`installed` 不是 spec 字符串),而发布到 npm 的本地包(如 `@nimoxie/dsh-model-organizer`)连 registry 404 签名都不命中 —— 所以 host 半区挂了 `GET /api/ui-tweaks/local-plugins`(读 profile `package.json` 的 dependencies,按 source 分两类回给面板:**local** = `link:`/`file:` 本地路径,标「本地链接」;**remote** = `git:`/`github:`/`http` 直装,标「Git
|
|
47
|
+
- `listBundles()` 不透传安装来源(`installed` 不是 spec 字符串),而发布到 npm 的本地包(如 `@nimoxie/dsh-model-organizer`)连 registry 404 签名都不命中 —— 所以 host 半区挂了 `GET /api/ui-tweaks/local-plugins`(读 profile `package.json` 的 dependencies,按 source 分两类回给面板:**local** = `link:`/`file:` 本地路径,标「本地链接」;**remote** = `git:`/`github:`/`http` 直装,标「Git 直装」;仅回环放行);0.1.5 起还有第二条回环路由 `POST /api/ui-tweaks/normalize-excludes`(见上面的归并器)。两者都**不参与 registry 比对**——即使 npm 上有同名包(如 `dsh-ego-browser` 有 `github:` 声明、registry 也存在 0.8.6),装的也是仓库源码、与 npm 版不是一回事。client 每轮检查先取这份名单,取不到(host 半区未重启)再退回 404 签名(404 时也按 `installed` 来源区分 local/remote)。**改 host 半区必须重启 dsh/客户端才生效**(client bundle 刷新即可)。
|
|
44
48
|
profile 目录定位:先从 `import.meta.url` 向上找带 `dsh.profile.bundles` 且包含自身的 package.json(npm 直装必命中);软链安装时模块被解析到 profile 外的真实目录,退回 `DSH_HOME/profiles/*` 扫描,多 profile 命中时按 `ELECTRON_RUN_AS_NODE` 区分 desktop/web。
|
|
45
49
|
|
|
46
50
|
### 其它要点
|
|
47
51
|
|
|
48
|
-
- **更新主路径全部走官方 remote API**(host
|
|
52
|
+
- **更新主路径全部走官方 remote API**(host 半区两条回环路由:本地包识别 + 豁免表归并):`pluginManager.listBundles()`(清单+版本)、`installBundle("<名>@版本", { enabled, requestId })`(更新)、`waitForInstall(requestId)`(**仅**响应丢失后的恢复)、`cancelInstall(requestId)`(取消,回 `{status}`)。
|
|
49
53
|
- **`installBundle` 是「等到装完才返回」的调用,它的返回值就是终态**(0.1.4 的关键纠正):remote 信封是 `{ok, value}`,`value` 即 `{changed, application, stage, target, enabled?, error?, warnings?, packageResult?, bundle?, pendingBuilds?, approvedBuilds?, registries?, failedAt?}`。官方桌面端插件页也是 `await installBundle(...)` 之后直接 `settleInstall(result.value)`。
|
|
50
54
|
- **`waitForInstall` 不是进度轮询器**(**0.1.3 的理解是错的,那正是「更新永远超时」的真因**):它只用于**响应丢失后**恢复同一请求的结果,请求不在活动中时返回 `null`,而且**已完成的结果不保留**。`installBundle` 已经 await 到装完,此时再轮询 `waitForInstall` 必然一路读到 `null` → 空转到 240s 看门狗 → 面板报「更新超时」,真实结果(成功或 pnpm 报错)全被丢掉。0.1.4 起:正常路径只读 `res.value`;**仅当信封本身失败(`res.ok === false`,结果未知)**才调一次 `waitForInstall` 恢复,仍为 `null` 就如实报「已发起·未确认」。
|
|
51
|
-
- **终态判定读 `application`**:`applied` / `restart-required`(都算成功,后者提示重启)→ 回读版本核对;`cancelled` → 该行退回「有新版本」(取消不是失败,仍可重试);`failed` → 行标「更新失败」,并把 `error.code`(译成中文)+ `error.diagnostic` + `packageResult.kind/exitCode/output/logPath` + `pendingBuilds` + `failedAt` 一起写进行内 `why
|
|
55
|
+
- **终态判定读 `application`**:`applied` / `restart-required`(都算成功,后者提示重启)→ 回读版本核对;`cancelled` → 该行退回「有新版本」(取消不是失败,仍可重试);`failed` → 行标「更新失败」,并把 `error.code`(译成中文)+ `error.diagnostic` + `packageResult.kind/exitCode/output/logPath` + `pendingBuilds` + `failedAt` 一起写进行内 `why`(**成熟期违规例外**:报错里带 `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` 时不落 `failed`,判成 `cooling`,并把反推出来的窗口记进会话供预判复用);`overridden` → 提示被覆盖、可重试;未知值一律按「结果未知」处理,不硬判成功。
|
|
52
56
|
- **回读版本是唯一成功判据**:只有在 `listBundles()` 里读到该行版本、且**与安装前不同**时才判「已是最新」。回读**只用 `pluginManager.listBundles()`**——`pluginInventory` 只有 `list()` 没有 `listBundles`(踩过,会造成「回读版本失败」假阳性);装完瞬间清单可能短暂不可用,重试 3 次(1.5s 间隔)。比对基准 `busyBefore` 在 `startOne` 抓取,**不受中途「重新检查」重建行的影响**。
|
|
53
57
|
- **registry 比对在浏览器直连**:`registry.npmjs.org` 带 CORS *(实测),逐包**串行** fetch `/latest`,不打并发。代价要知道:比对源是 npmjs,而安装走 pnpm 自己的 registry(本机 `.npmrc` 指向 npmmirror)——镜像尚未同步到的新版本会「面板说有、装时报 no-matching-version」(见「待办」)。
|
|
54
58
|
- **取消要分清对象**(0.1.4 修):排队项点取消只把它自己摘出队列(`cancelQueued`);在装项点取消才 `cancelInstall(requestId)`,且**保留队列**,等这次落定后 `done() → pump()` 接着装下一项。旧实现 `requestCancel` 里一句 `queue = []` 把整个队列清空,于是「等待更新」的项全部退回「有新版本」不再开始——这就是「取消一项后,等待的项不更新、反而变回有新版本」的真因。`cancelInstall` 的回值 `{status: cancelled|not-running|too-late}` 也要读:`too-late` 说明已进入应用阶段、取消不掉,得如实告诉用户。
|
|
55
59
|
- **重新检查必须回填在飞状态**(0.1.4 修):`checkNow` 重建 `rows` 会把「更新中/等待更新」抹成「有新版本」,而 `busyName`/`queue` 仍在——此时点那一行会撞上 `enqueue` 的 `name === busyName` 保护而**静默无反应**(这就是「重新检查后无法更新」的真因),真正在装的那一项也失去取消入口。修法是重建行后立刻 `reconcileInstallStates()` 把 busy/queuing 盖回去,且这两种行不参与 registry 比对。
|
|
56
60
|
- **总看门狗(0.1.4 起只是兜底)**:正常路径一次 `installBundle` 就定案,不必再等 240s;看门狗只覆盖 fetchLatest(现场查 registry,fetch 无超时)这类异常分支,触发时报「结果未知」并 `cancelInstall`。
|
|
57
61
|
- **`enabled` 用清单里的真实状态**:`installBundle` 的 `enabled` 传 `!(row.enabled === false)`,保留用户手动禁用的选择(旧实现一律 `enabled: true`,会把禁用的插件重新启用)。
|
|
58
|
-
- **失败原因要可见**:`fail` / `unchanged` / `unconfirmed` / `error` 行在行下多渲染一行 `.why`——只放在 `title` 悬停提示里,等于 pnpm 的报错没显示。
|
|
62
|
+
- **失败原因要可见**:`fail` / `unchanged` / `unconfirmed` / `error` / `cooling` 行在行下多渲染一行 `.why`——只放在 `title` 悬停提示里,等于 pnpm 的报错没显示。0.1.5 起渲染前先 `stripAnsi`(CSI/OSC/控制字符/CRLF,否则用户第一屏是一串 `鈺?` 残渣),并把 `diagnostic` 与 `packageResult.output` 互相包含时只留更完整的一份(dsh 常把同一份 pnpm 输出摘成两段);`cooling` 的完整诊断默认折叠,点标签才展开(`.dtl`)。
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
## 动画修复(模块 4)
|
|
@@ -81,15 +85,20 @@
|
|
|
81
85
|
- [ ] 会话内重启 dsh 的入口(需要 host 配合或官方重启 API,未调研)。
|
|
82
86
|
- [x] 0.1.3:`waitForInstall` 终态按 zod schema 收紧了字段名(`application`/`stage`/`failedAt`)+ 全程总看门狗。**注意:这一版的结论是错的**——字段名猜对了,但把 `waitForInstall` 当进度轮询器用,而它只做「响应丢失恢复」;见 0.1.4 条目。
|
|
83
87
|
- [x] 0.1.4:终态改读 `installBundle().value`(`waitForInstall` 只在信封失败时兜一次);取消不再清空队列、排队项可单独取消;`checkNow` 回填 busy/queuing;失败原因(`error`/`packageResult`/`pendingBuilds`)落到面板;`enabled` 沿用清单里的真实状态。
|
|
88
|
+
- [x] 0.1.5:新增「冷静期中」(cooling)——预判(全量 packument 的 `time`)+ 报错反推成熟时刻,标签不可点击安装、`enqueue` 二次拦截;host 半区新增 `minimumReleaseAgeExclude` 归并器与 `POST /api/ui-tweaks/normalize-excludes`(安装成功/重新检查时 fire-and-forget);失败文案剥 ANSI、同一份 pnpm 输出去重、cooling 详情折叠;纠正 0.1.4 关于 `minimumReleaseAge` 的半个结论。
|
|
84
89
|
- [ ] 比对 registry 与安装 registry 对齐:面板查 `registry.npmjs.org`,pnpm 走 `.npmrc`(npmmirror)。镜像未同步的新版本会造成「面板说有新版、装时报 no-matching-version」。可用 `pluginManager.registries()` 取实际 registry 再比对(需先确认镜像的 CORS)。
|
|
85
90
|
- [ ] `pendingBuilds` 的「允许这些脚本并重试」入口(`installBundle(spec, { approvedBuilds })`)。现在只在失败行里列出待授权名单。
|
|
86
91
|
- [ ] `removable === false` / `optional` / 带 `readOnlyReason` 的 bundle 在面板隐藏或标注——否则点了会以 `management-required` 失败。
|
|
87
92
|
- [ ] `installBundle` 的 `registry` 参数补齐(官方传了,本插件省略)。
|
|
88
93
|
- [ ] 插件被禁用/卸载时的运行时清理(目前 DOM 残留到页面刷新为止)。
|
|
94
|
+
- [ ] 归并器收尾:`.bak-<时间戳>` 会随每次改写累积(改成只留最近 N 个或固定单名);`minimumReleaseAgeExclude` 里出现空行/纯注释行时扫描会提前结束(只漏收敛、不误删),可顺手修成跳过空行继续扫。
|
|
95
|
+
- [ ] cooling 的反推窗口依赖"报错新鲜度":解析被延迟时反推窗口会偏大(现有 30 天上限兜底),偏大表现为**多标**冷静期;可考虑优先用 host 侧策略真值而不是反推。
|
|
96
|
+
- [ ] `鈺?`/`鉁?` 这类残渣的根因在 host 侧读 pnpm stdout 时的解码(UTF-8 被按 CP936 解),client 只能剥 ANSI;根治要动解码或让 host 回报已解码文本。
|
|
89
97
|
|
|
90
98
|
## 发布
|
|
91
99
|
|
|
92
100
|
- npm 不允许覆盖已发布的版本号:**先升版本,再提交,最后发布**。
|
|
101
|
+
- **发版前先 `npm pack --dry-run` 看一眼清单**:`files` 里曾把开发笔记写成 `DEVELOPING.md`(实际文件名是 `DEVELOPMENT.md`),于是它一直没进包;0.1.5 已修。
|
|
93
102
|
- 版本号同时在 `package.json` 和 `lib/client.js` 的 `BUILD` 常量里(改两处)。发版命令:
|
|
94
103
|
```sh
|
|
95
104
|
npm version patch # 或手改 package.json 的 version;记得同步 BUILD
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 NimoXie
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NimoXie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
**插件更新检查**
|
|
23
23
|
- 插件管理页顶部多一枚「检查更新」按钮(刷新按钮左侧,样式与「添加插件」按钮一致):比对已装插件(官方 `@deepseek-ai` 插件除外)与 npm registry 的版本,有新版本可点上就地更新,支持全部更新与中途取消
|
|
24
24
|
- 不检查 dsh 本体,也不检查 `@deepseek-ai` 官方插件——两者的更新都由 dsh 本体的更新流程负责(桌面端自带更新器;web 端本体升级走 `npm install -g @deepseek-ai/dsh@next`)
|
|
25
|
+
- 新版本还没过 pnpm 的发布时长窗口(`minimumReleaseAge`,由 dsh 注入给自带 pnpm,默认 24 小时)时,该行标 **「冷静期中」** 并给出可更新时间(精确到分钟,发布时间与判定 cutoff 都能从报错里读到);点它只展开详情,**不会**发动一次注定失败的安装
|
|
26
|
+
- 安装成功后自动把 profile 的 `minimumReleaseAgeExclude` 归并成「每包一条」,修掉「同一个包名出现多条时只有靠前那条生效、陈旧条目静默遮蔽新条目,导致此后每次安装都失败」这个坑(由 host 半区静默完成,无界面)
|
|
25
27
|
|
|
26
28
|
## 安装
|
|
27
29
|
|
|
@@ -55,8 +57,9 @@ dsh plugin --profile <profile> add link:/absolute/path/to/dsh-ui-tweaks
|
|
|
55
57
|
|
|
56
58
|
- 非 npm 来源的插件没有可比的 registry 版本,直接跳过比对:`link:` / `file:` 本地路径标「本地链接」;`git:` / `github:` / `http` 直装标「Git 直装」(例如 `github:Fisfzy/dsh-ego-browser` 会显示为 Git 直装,即使 npm 上有同名包,装的也是仓库源码、与 npm 版本不是一回事)
|
|
57
59
|
- `@deepseek-ai` 官方插件直接从检查列表里剔除:它们的更新由 dsh 本体流程负责
|
|
58
|
-
- 更新走 profile 内的插件安装接口,与官方插件管理页同源;安装按 registry 解析出的**精确版本号**发起(而非 `@latest
|
|
59
|
-
-
|
|
60
|
+
- 更新走 profile 内的插件安装接口,与官方插件管理页同源;安装按 registry 解析出的**精确版本号**发起(而非 `@latest`),装的就是面板显示的那一个版本
|
|
61
|
+
- **精确版本号并不能绕过 pnpm 的发布时长策略**(`minimumReleaseAge`,由 dsh 注入,默认 24 小时,不在任何配置文件里):门禁在解析之前先校验 profile 现有的 lockfile,窗口内的版本会让整个 profile 的安装被**整体中止**(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`,exit 1)。所以这类行被判成「冷静期中」而不是「更新失败」——发布满 24 小时后重试即可
|
|
62
|
+
- 更新失败时行内会直接给出原因(pnpm 的输出、退出码与日志路径),不再只说一句「失败」;渲染前会剥掉终端转义序列(否则第一屏是一串乱码),同一份 pnpm 输出被 dsh 摘成两段时只留更完整的一份;失败行可点击重试。冷静期不是失败,不渲染那一长串,只在展开详情里给完整诊断。取消只作用于你点的那一项:取消排队的项不影响正在装的,取消正在装的也不会丢掉后面排队的(它会自动接着装)
|
|
60
63
|
- 面板比对用的是 npm 官方 registry,而安装走你本机 pnpm 配置的源(如 npmmirror 镜像)。镜像尚未同步到的新版本会出现「面板说有新版、装时报找不到版本」,稍后镜像同步后重试即可
|
|
61
64
|
- 个别插件(如社区插件管理页扩展)自带单独的「检查更新」入口,与本插件的检查互不关联,结果可能不同
|
|
62
65
|
- 与启动器 dsh-web-shell 的分工:对话区滚动条、宽表格修复等界面增强已由本插件接管,dsh-web-shell 自身不再注入;其「检查更新」按钮仍负责 **dsh 本体**的比对与升级提示
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# dsh-ui-tweaks bundle patch: one host row over the web profile.
|
|
2
|
-
# The row name resolves from the profile's own node_modules where
|
|
3
|
-
# `dsh plugin --profile web add <path>` installed this package.
|
|
4
|
-
# Scoped 包名的 name 必须写全(同 @linxin666 系插件),loader 按它解析 node_modules。
|
|
5
|
-
- insert:
|
|
6
|
-
- id: ui-tweaks
|
|
7
|
-
name: '@nimoxie/dsh-ui-tweaks'
|
|
1
|
+
# dsh-ui-tweaks bundle patch: one host row over the web profile.
|
|
2
|
+
# The row name resolves from the profile's own node_modules where
|
|
3
|
+
# `dsh plugin --profile web add <path>` installed this package.
|
|
4
|
+
# Scoped 包名的 name 必须写全(同 @linxin666 系插件),loader 按它解析 node_modules。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: ui-tweaks
|
|
7
|
+
name: '@nimoxie/dsh-ui-tweaks'
|
package/lib/client.js
CHANGED
|
@@ -14,7 +14,7 @@ window.__ModuleLoader__.load({
|
|
|
14
14
|
const module = { exports: {} };
|
|
15
15
|
const exports = module.exports;
|
|
16
16
|
|
|
17
|
-
const BUILD = "0.1.
|
|
17
|
+
const BUILD = "0.1.5";
|
|
18
18
|
|
|
19
19
|
/* 每个模块的注册各套一层 guard:失败只记 warn(座位/模块失败模式是「静默消失」,
|
|
20
20
|
必须主动吭声;但不能让一个模块的异常打断同文件里其它模块的 apply)。 */
|
|
@@ -359,11 +359,25 @@ window.__ModuleLoader__.load({
|
|
|
359
359
|
* registry 比对从浏览器直连(registry.npmjs.org 带 CORS *)。
|
|
360
360
|
*
|
|
361
361
|
* 更新严格串行(pnpm 对 profile 的 package.json 有文件锁,并发只会在锁上
|
|
362
|
-
* 排队);安装 spec 用面板解析出的**精确版本**(`name@版本`,不是 @latest
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
362
|
+
* 排队);安装 spec 用面板解析出的**精确版本**(`name@版本`,不是 @latest),
|
|
363
|
+
* 装完仍回读实际版本兜底(registry 与 pnpm 解析可能不一致),版本没变才是
|
|
364
|
+
* 「暂缓更新」。
|
|
365
|
+
*
|
|
366
|
+
* minimumReleaseAge(DSH 自带 pnpm 11 注入,本机策略 1440 分钟):
|
|
367
|
+
* 门禁在**解析之前**校验磁盘上已有的 lockfile,任何一个 entry 的发布时间
|
|
368
|
+
* 落在窗口内就整体中止整个 profile 的安装,报
|
|
369
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION(exit 1)—— 写显式版本号并不能
|
|
370
|
+
* 豁免(旧注释在这里写反过,已纠正)。
|
|
371
|
+
* 真正的豁免表是 profile 的 minimumReleaseAgeExclude,但**同一个包名出现多
|
|
372
|
+
* 条时只有文件中靠前的那条生效**:陈旧条会静默遮蔽新条(实测 [0.69.0,
|
|
373
|
+
* 0.61.0] 通过、[0.61.0, 0.69.0] 失败,只差顺序)。所以「再点一次 = 知情
|
|
374
|
+
* 并继续」在豁免表没被归并干净时会失败,而且紧接着连累整个 profile 的后续
|
|
375
|
+
* 安装。这张表由 host 半边自动归并成「每包一条」
|
|
376
|
+
* (POST /api/ui-tweaks/normalize-excludes),client 只负责在安装成功后 /
|
|
377
|
+
* 面板重新检查时尽力而为地触发一次。
|
|
378
|
+
* 窗口内的版本在面板上标成「冷静期中」(cooling):发布时间与门禁 cutoff 都
|
|
379
|
+
* 能从 pnpm 报错文本里读出来,成熟时刻 = 发布时间 + (门禁运行时刻 − cutoff);
|
|
380
|
+
* 预判则拉全量 packument 的 time[版本] 自己算(见下面的 cooling 两段)。
|
|
367
381
|
* ==================================================================== */
|
|
368
382
|
function installPluginUpdates(ctx) {
|
|
369
383
|
if (window.__DSHUIW_UPDATES__) { return; }
|
|
@@ -413,6 +427,258 @@ window.__ModuleLoader__.load({
|
|
|
413
427
|
});
|
|
414
428
|
}
|
|
415
429
|
|
|
430
|
+
/* ---- 报错文案 / 冷静期:纯函数区 ---------------------------------------
|
|
431
|
+
* 这一段不读任何闭包状态(策略窗口、当前时刻、错误码表都作为参数传入),
|
|
432
|
+
* 因此可以单独喂样例跑断言。
|
|
433
|
+
* -------------------------------------------------------------------- */
|
|
434
|
+
var MAX_POLICY_MS = 30 * 24 * 60 * 60 * 1000; /* 反推窗口的合理上限(30 天) */
|
|
435
|
+
|
|
436
|
+
/* pnpm 输出带 ANSI 转义(CSI 色码 / OSC 超链接)与不可打印控制字符,直接渲染
|
|
437
|
+
就是用户第一屏看到的「鈺?」「鉁?」这类残渣;这里统一剥掉(保留 \n \t),
|
|
438
|
+
并把 CRLF 归一成 \n(pnpm 日志是 CRLF)。 */
|
|
439
|
+
function stripAnsi(s) {
|
|
440
|
+
return String(s == null ? "" : s)
|
|
441
|
+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "") /* OSC */
|
|
442
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "") /* CSI */
|
|
443
|
+
.replace(/\u001b[@-Z\\-_]/g, "") /* 其它两字节转义 */
|
|
444
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") /* 控制字符 */
|
|
445
|
+
.replace(/\r\n?/g, "\n");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isoToMs(s) {
|
|
449
|
+
var t = Date.parse(String(s == null ? "" : s));
|
|
450
|
+
return isFinite(t) ? t : NaN;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* registry 全量 packument 的 time 字段 → {版本: 发布时间ms}(created / modified
|
|
454
|
+
是元数据不是版本)。/<pkg>/latest 与 abbreviated packument
|
|
455
|
+
(Accept: application/vnd.npm.install-v1+json)都没有 time 字段,别用它们。 */
|
|
456
|
+
function packumentTimes(doc) {
|
|
457
|
+
var out = {};
|
|
458
|
+
if (!doc || !doc.time || typeof doc.time !== "object") { return out; }
|
|
459
|
+
for (var k in doc.time) {
|
|
460
|
+
if (!Object.prototype.hasOwnProperty.call(doc.time, k)) { continue; }
|
|
461
|
+
if (k === "created" || k === "modified") { continue; }
|
|
462
|
+
var t = isoToMs(doc.time[k]);
|
|
463
|
+
if (isFinite(t)) { out[k] = t; }
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* 门禁报错的解析(真实样例):
|
|
469
|
+
dsh-context@0.69.0 was published at 2026-10-11T18:46:04.000Z,
|
|
470
|
+
within the minimumReleaseAge cutoff (2026-10-11T07:56:04.065Z)
|
|
471
|
+
cutoff = 门禁运行时刻 − 窗口 ⇒ 窗口 = 现在 − cutoff,
|
|
472
|
+
成熟时刻 = 发布时间 + 窗口。
|
|
473
|
+
真实路径是「安装失败回调里立刻解析刚回来的报错」,现在 ≈ 门禁运行时刻,误差
|
|
474
|
+
只有秒级;对陈旧报错文本反推会偏大,故窗口只接受 (0, 30 天],越界退回
|
|
475
|
+
defaultWindowMs(会话已从真实报错里学到的值,默认 1440 分钟)。 */
|
|
476
|
+
function parseCoolingFailure(text, nowMs, defaultWindowMs) {
|
|
477
|
+
var s = String(text == null ? "" : text);
|
|
478
|
+
if (!/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge|supply-chain polic/i.test(s)) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
var out = {
|
|
482
|
+
violation: true, packageName: "", version: "",
|
|
483
|
+
publishedMs: NaN, cutoffMs: NaN, windowMs: NaN, matureAtMs: NaN
|
|
484
|
+
};
|
|
485
|
+
var mPub = /published at\s+([0-9T:.\-Z]+)/.exec(s);
|
|
486
|
+
var mCut = /\bcutoff\b[^0-9]{0,8}([0-9T:.\-Z]+)/.exec(s);
|
|
487
|
+
var mName = /(@?[^\s@\/]+(?:\/[^\s@\/]+)?)@([0-9][0-9A-Za-z.\-+]*)/.exec(s);
|
|
488
|
+
if (mName) { out.packageName = mName[1]; out.version = mName[2]; }
|
|
489
|
+
if (mPub) { out.publishedMs = isoToMs(mPub[1]); }
|
|
490
|
+
if (mCut) { out.cutoffMs = isoToMs(mCut[1]); }
|
|
491
|
+
var win = (isFinite(nowMs) && isFinite(out.cutoffMs)) ? (nowMs - out.cutoffMs) : NaN;
|
|
492
|
+
if (!isFinite(win) || win <= 0 || win > MAX_POLICY_MS) {
|
|
493
|
+
win = (isFinite(defaultWindowMs) && defaultWindowMs > 0) ? defaultWindowMs : NaN;
|
|
494
|
+
}
|
|
495
|
+
out.windowMs = win;
|
|
496
|
+
if (isFinite(out.publishedMs) && isFinite(win)) { out.matureAtMs = out.publishedMs + win; }
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* 从一次安装结果里认出「成熟期违规」:文本取自 error.message / diagnostic /
|
|
501
|
+
code / packageResult.output(同一份 pnpm 输出的不同片段,合起来看)。 */
|
|
502
|
+
function coolingFromResult(st, nowMs, defaultWindowMs, codeText) {
|
|
503
|
+
if (!st || typeof st !== "object") { return null; }
|
|
504
|
+
var pr = st.packageResult;
|
|
505
|
+
var text = stripAnsi([
|
|
506
|
+
st.error && st.error.message,
|
|
507
|
+
st.error && st.error.diagnostic,
|
|
508
|
+
st.error && st.error.code,
|
|
509
|
+
pr && pr.output
|
|
510
|
+
].filter(function (x) { return !!x; }).join("\n"));
|
|
511
|
+
var info = parseCoolingFailure(text, nowMs, defaultWindowMs);
|
|
512
|
+
if (!info) { return null; }
|
|
513
|
+
return {
|
|
514
|
+
violation: true, version: info.version, packageName: info.packageName,
|
|
515
|
+
publishedAt: info.publishedMs, matureAt: info.matureAtMs,
|
|
516
|
+
windowMs: info.windowMs,
|
|
517
|
+
detail: failureText(st, codeText)
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* 失败原因必须落到面板上:旧实现只回一句「安装失败」,把 pnpm 的真实报错
|
|
522
|
+
(如 ERR_PNPM_UNEXPECTED_STORE)连同 logPath 一起丢掉,用户只看到「超时」。
|
|
523
|
+
diagnostic 与 packageResult.output 常是同一份 pnpm 输出的两段(dsh 摘的尾部
|
|
524
|
+
vs 原始输出),两段都贴会让同一段报错在气泡里出现两遍:一段被另一段包含时
|
|
525
|
+
只留更完整的那份;渲染前先剥掉转义与控制字符。 */
|
|
526
|
+
function failureText(st, codeText) {
|
|
527
|
+
var tbl = codeText || {};
|
|
528
|
+
var parts = [];
|
|
529
|
+
var code = st && st.error && st.error.code;
|
|
530
|
+
parts.push("安装失败" + (code ? ":" + (tbl[code] || code) : ""));
|
|
531
|
+
var diag = stripAnsi(st && st.error && st.error.diagnostic).replace(/^\s+|\s+$/g, "");
|
|
532
|
+
var pr = st && st.packageResult;
|
|
533
|
+
var out = stripAnsi(pr && pr.output).replace(/^\s+|\s+$/g, "");
|
|
534
|
+
if (diag && out && (diag.indexOf(out) >= 0 || out.indexOf(diag) >= 0)) {
|
|
535
|
+
if (out.length > diag.length) { diag = ""; } else { out = ""; }
|
|
536
|
+
}
|
|
537
|
+
if (diag) { parts.push(diag); }
|
|
538
|
+
if (pr) {
|
|
539
|
+
if (pr.kind) { parts.push("分类:" + pr.kind); }
|
|
540
|
+
if (pr.exitCode) { parts.push("pnpm 退出码 " + pr.exitCode); }
|
|
541
|
+
if (out) { parts.push("输出:" + out.slice(-800)); }
|
|
542
|
+
if (pr.logPath) { parts.push("日志:" + pr.logPath); }
|
|
543
|
+
}
|
|
544
|
+
var pend = st && st.pendingBuilds;
|
|
545
|
+
if (pend && pend.length) { parts.push("待授权的构建脚本:" + pend.join("、") + "(需在官方插件页授权后重试)"); }
|
|
546
|
+
if (st && st.failedAt) { parts.push("失败环节:" + st.failedAt); }
|
|
547
|
+
return parts.join(" | ");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
|
551
|
+
/* 绝对时间用本地时间(MM-DD HH:mm) */
|
|
552
|
+
function fmtLocal(ms) {
|
|
553
|
+
var d = new Date(ms);
|
|
554
|
+
return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
555
|
+
}
|
|
556
|
+
/* 相对剩余时间:16h49m / 49m */
|
|
557
|
+
function fmtLeft(ms) {
|
|
558
|
+
var s = Math.max(0, Math.round(ms / 1000));
|
|
559
|
+
var h = Math.floor(s / 3600);
|
|
560
|
+
var m = Math.floor((s - h * 3600) / 60);
|
|
561
|
+
if (h > 0) { return h + "h" + (m > 0 ? pad2(m) + "m" : ""); }
|
|
562
|
+
return m + "m";
|
|
563
|
+
}
|
|
564
|
+
function fmtWindow(ms) {
|
|
565
|
+
if (!isFinite(ms) || ms <= 0) { return ""; }
|
|
566
|
+
var mins = Math.round(ms / 60000);
|
|
567
|
+
if (mins < 60) { return mins + " 分钟"; }
|
|
568
|
+
var hrs = mins / 60;
|
|
569
|
+
return (hrs === Math.floor(hrs) ? String(hrs) : hrs.toFixed(1)) + " 小时";
|
|
570
|
+
}
|
|
571
|
+
/* cooling 行副标题:绝对时间(本地)+ 相对剩余时间 + 「现在装会被门禁整体中止、
|
|
572
|
+
不是本插件的问题」。 */
|
|
573
|
+
function coolingWhyText(info) {
|
|
574
|
+
var i = info || {};
|
|
575
|
+
var out = (i.version ? String(i.version) : "该版本") + " 处于供应链冷静期";
|
|
576
|
+
var win = fmtWindow(i.windowMs);
|
|
577
|
+
if (win) { out += "(发布未满 " + win + ")"; }
|
|
578
|
+
var mature = i.matureAt;
|
|
579
|
+
if (isFinite(mature)) {
|
|
580
|
+
var left = mature - (isFinite(i.nowMs) ? i.nowMs : 0);
|
|
581
|
+
out += "," + fmtLocal(mature) + " 后可更新" +
|
|
582
|
+
(left > 0 ? "(剩 " + fmtLeft(left) + ")" : "(已到期,点「重新检查」刷新)");
|
|
583
|
+
} else {
|
|
584
|
+
out += ",成熟时刻没能从报错里解析出来";
|
|
585
|
+
}
|
|
586
|
+
return out + "。现在点安装会被 pnpm 的 minimumReleaseAge 门禁整体中止(连累整个 profile 的后续安装),不是本插件的问题。";
|
|
587
|
+
}
|
|
588
|
+
/* ---- 纯函数区结束 ---- */
|
|
589
|
+
|
|
590
|
+
/* ---- 供应链冷静期(cooling):预判数据源与护栏 --------------------------
|
|
591
|
+
* 预判 = 拉全量 packument 读该版本的发布时间(/<pkg>/latest 和 abbreviated
|
|
592
|
+
* packument 都没有 time),发布时间 + 策略窗口 > 现在 → 该行标 cooling。
|
|
593
|
+
* 会话级缓存:一个包只拉一次、缓存 10 分钟,失败(含超时)也缓存 —— 否则每次
|
|
594
|
+
* 重绘都会重拉,就是重试风暴。超过 8 秒用 AbortController 掐断、体积异常大直接
|
|
595
|
+
* 放弃:这两种情况都只是「不做预判」,该行保持「有新版本」,不报错、不重试。
|
|
596
|
+
* 请求串行(同 registry 比对一样,别对 registry 打并发)。
|
|
597
|
+
* 策略窗口优先用真实报错反推出来的值(见 coolingFromResult),默认 1440 分钟。
|
|
598
|
+
* -------------------------------------------------------------------- */
|
|
599
|
+
var DEFAULT_POLICY_MS = 1440 * 60000; /* pnpm minimumReleaseAge 默认 1440 分钟 */
|
|
600
|
+
var policyWindowMs = DEFAULT_POLICY_MS;
|
|
601
|
+
var PACKUMENT_TTL_MS = 10 * 60000;
|
|
602
|
+
var PACKUMENT_TIMEOUT_MS = 8000;
|
|
603
|
+
var PACKUMENT_MAX_CHARS = 4 * 1024 * 1024;
|
|
604
|
+
var packumentCache = {}; /* name → {at, ok, time:{版本:ms}, err} */
|
|
605
|
+
var packumentChain = Promise.resolve(); /* 串行闸门 */
|
|
606
|
+
|
|
607
|
+
function fetchPackumentTimes(name, cb) {
|
|
608
|
+
var hit = packumentCache[name];
|
|
609
|
+
if (hit && Date.now() - hit.at < PACKUMENT_TTL_MS) { cb(hit); return; }
|
|
610
|
+
var ctl = null, timer = null, done = false;
|
|
611
|
+
function settle(res) {
|
|
612
|
+
if (done) { return; }
|
|
613
|
+
done = true;
|
|
614
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
615
|
+
packumentCache[name] = res;
|
|
616
|
+
try { cb(res); } catch (e) {}
|
|
617
|
+
}
|
|
618
|
+
function begin() {
|
|
619
|
+
try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e1) {}
|
|
620
|
+
if (ctl) { timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, PACKUMENT_TIMEOUT_MS); }
|
|
621
|
+
var path = String(name).split("/").map(encodeURIComponent).join("/");
|
|
622
|
+
var opts = { cache: "no-store", headers: { Accept: "application/json" } };
|
|
623
|
+
if (ctl) { opts.signal = ctl.signal; }
|
|
624
|
+
return fetch("https://registry.npmjs.org/" + path, opts)
|
|
625
|
+
.then(function (r) {
|
|
626
|
+
if (!r || !r.ok) { throw new Error("HTTP " + (r ? r.status : "?")); }
|
|
627
|
+
return r.text();
|
|
628
|
+
})
|
|
629
|
+
.then(function (txt) {
|
|
630
|
+
txt = String(txt == null ? "" : txt);
|
|
631
|
+
if (txt.length > PACKUMENT_MAX_CHARS) { throw new Error("packument 体积异常大(" + txt.length + " 字符)"); }
|
|
632
|
+
var doc = null;
|
|
633
|
+
try { doc = JSON.parse(txt); } catch (e3) { throw new Error("packument 不是合法 JSON"); }
|
|
634
|
+
settle({ at: Date.now(), ok: true, time: packumentTimes(doc) });
|
|
635
|
+
})
|
|
636
|
+
.catch(function (e) { settle({ at: Date.now(), ok: false, err: msgOf(e), time: {} }); });
|
|
637
|
+
}
|
|
638
|
+
packumentChain = packumentChain.then(begin, begin).catch(function () {});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/* 预判:只对「有新版本」的行做。窗口内 → cooling(标签不可点击安装);
|
|
642
|
+
窗口外 / 数据源不可用 → 保持 update。 */
|
|
643
|
+
function applyCoolingGuess(name) {
|
|
644
|
+
var row = findRow(name);
|
|
645
|
+
if (!row || row.state !== "update" || !row.latest) { return; }
|
|
646
|
+
var ver = row.latest;
|
|
647
|
+
fetchPackumentTimes(name, function (res) {
|
|
648
|
+
var r = findRow(name);
|
|
649
|
+
/* 期间该行可能被安装 / 重查改过:只处理仍是「有新版本」且版本没变的行 */
|
|
650
|
+
if (!r || r.state !== "update" || r.latest !== ver) { return; }
|
|
651
|
+
if (!res || !res.ok || !res.time) { return; }
|
|
652
|
+
var pub = res.time[ver];
|
|
653
|
+
if (!isFinite(pub)) { return; }
|
|
654
|
+
var mature = pub + policyWindowMs;
|
|
655
|
+
if (!isFinite(mature) || Date.now() >= mature) { return; }
|
|
656
|
+
setRow(name, {
|
|
657
|
+
state: "cooling", publishedAt: pub, matureAt: mature, why: "",
|
|
658
|
+
detail: "registry 记录 " + ver + " 发布于 " + new Date(pub).toISOString() +
|
|
659
|
+
";minimumReleaseAge 策略窗口 " + fmtWindow(policyWindowMs) + "。" +
|
|
660
|
+
"pnpm 在解析之前校验 lockfile,窗口内的版本会让整个 profile 的安装整体中止" +
|
|
661
|
+
"(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION,exit 1)。"
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/* 安装成功后 / 面板重新检查时,尽力而为地让 host 半边把 profile 的
|
|
667
|
+
minimumReleaseAgeExclude 归并成「每包一条」:同一个包名出现多条时只有靠前的
|
|
668
|
+
那条生效,陈旧条会静默遮蔽新条,让下一次安装继续撞门禁。
|
|
669
|
+
fire-and-forget:不 await(不阻塞 UI)、失败不弹错、异常全吞
|
|
670
|
+
(host 未重启时这个路由就是 404)。 */
|
|
671
|
+
function normalizeExcludes() {
|
|
672
|
+
try {
|
|
673
|
+
fetch("/api/ui-tweaks/normalize-excludes", {
|
|
674
|
+
method: "POST", cache: "no-store",
|
|
675
|
+
headers: { "Content-Type": "application/json" },
|
|
676
|
+
body: "{}"
|
|
677
|
+
}).catch(function () {});
|
|
678
|
+
} catch (e) {}
|
|
679
|
+
}
|
|
680
|
+
/* ---- 冷静期预判区结束 ---- */
|
|
681
|
+
|
|
416
682
|
/* ---- remote 服务解析:ctx.inject 的 scope 优先(缓存复用),超时兜底直读 ctx ---- */
|
|
417
683
|
var savedScope = null;
|
|
418
684
|
var got = null; // checkNow 的服务句柄缓存({pm})
|
|
@@ -469,8 +735,12 @@ window.__ModuleLoader__.load({
|
|
|
469
735
|
function isRemoteSrc(src) { return /^(git[+:@]|github:|git:|http)/.test(String(src || "")); }
|
|
470
736
|
|
|
471
737
|
/* ---- 面板状态 ---- */
|
|
472
|
-
var rows = []; // {name, version, latest, state, why}
|
|
473
|
-
|
|
738
|
+
var rows = []; // {name, version, latest, state, why, publishedAt, matureAt, detail}
|
|
739
|
+
/* state: pending | checking | current | update | local | remote | error | queuing | busy
|
|
740
|
+
| done | unchanged | unconfirmed | fail | cooling
|
|
741
|
+
cooling(冷静期中):还有比已装更新的版本,但它的发布时间没满 minimumReleaseAge
|
|
742
|
+
窗口 —— matureAt = 成熟时刻,detail = 展开后的详情文本;该标签不可点击安装。 */
|
|
743
|
+
var revealedCooling = {}; // name → true:该行详情已展开(点 cooling 标签切换)
|
|
474
744
|
var queue = []; // 待更新名单(串行队列)
|
|
475
745
|
var busyName = ""; // 正在更新的那个
|
|
476
746
|
var busyReq = "";
|
|
@@ -498,6 +768,8 @@ window.__ModuleLoader__.load({
|
|
|
498
768
|
function checkNow() {
|
|
499
769
|
if (checking) { return; }
|
|
500
770
|
checking = true;
|
|
771
|
+
/* 重新检查 / 打开面板:顺手让 host 半边归并一次豁免表(fire-and-forget) */
|
|
772
|
+
normalizeExcludes();
|
|
501
773
|
rows = [];
|
|
502
774
|
emit();
|
|
503
775
|
var pm = null;
|
|
@@ -564,6 +836,8 @@ window.__ModuleLoader__.load({
|
|
|
564
836
|
row.latest = v || "";
|
|
565
837
|
row.state = v && cmpSemver(v, row.version) > 0 ? "update" : "current";
|
|
566
838
|
emit();
|
|
839
|
+
/* 有新版本才做冷静期预判(异步,不阻塞比对链) */
|
|
840
|
+
if (row.state === "update") { applyCoolingGuess(row.name); }
|
|
567
841
|
}).catch(function (e) {
|
|
568
842
|
row.state = e && e.notFound ? notFoundState(row) : "error";
|
|
569
843
|
row.why = String(e && e.message || e);
|
|
@@ -622,27 +896,8 @@ window.__ModuleLoader__.load({
|
|
|
622
896
|
"incompatible-version": "版本与当前 dsh 不兼容",
|
|
623
897
|
"operation-error": "安装命令执行失败(pnpm 非零退出)"
|
|
624
898
|
};
|
|
625
|
-
/*
|
|
626
|
-
|
|
627
|
-
function failureText(st) {
|
|
628
|
-
var parts = [];
|
|
629
|
-
var code = st && st.error && st.error.code;
|
|
630
|
-
parts.push("安装失败" + (code ? ":" + (ERROR_CODE_TEXT[code] || code) : ""));
|
|
631
|
-
var diag = st && st.error && st.error.diagnostic;
|
|
632
|
-
if (diag) { parts.push(String(diag)); }
|
|
633
|
-
var pr = st && st.packageResult;
|
|
634
|
-
if (pr) {
|
|
635
|
-
if (pr.kind) { parts.push("分类:" + pr.kind); }
|
|
636
|
-
if (pr.exitCode) { parts.push("pnpm 退出码 " + pr.exitCode); }
|
|
637
|
-
var out = String(pr.output || "").trim();
|
|
638
|
-
if (out) { parts.push("输出:" + out.slice(-800)); }
|
|
639
|
-
if (pr.logPath) { parts.push("日志:" + pr.logPath); }
|
|
640
|
-
}
|
|
641
|
-
var pend = st && st.pendingBuilds;
|
|
642
|
-
if (pend && pend.length) { parts.push("待授权的构建脚本:" + pend.join("、") + "(需在官方插件页授权后重试)"); }
|
|
643
|
-
if (st && st.failedAt) { parts.push("失败环节:" + st.failedAt); }
|
|
644
|
-
return parts.join(" | ");
|
|
645
|
-
}
|
|
899
|
+
/* failureText 与 coolingFromResult 都在文件上方的「纯函数区」里(错误码表作为
|
|
900
|
+
参数传入),这里只保留这张码表;旧的长文案实现已删除,避免两份逻辑漂移。 */
|
|
646
901
|
function lostReplyText(res) {
|
|
647
902
|
var m = res && res.error && res.error.message;
|
|
648
903
|
return "安装请求未返回结果" + (m ? ":" + String(m) : "");
|
|
@@ -650,6 +905,10 @@ window.__ModuleLoader__.load({
|
|
|
650
905
|
|
|
651
906
|
function enqueue(name) {
|
|
652
907
|
if (name === busyName) { return; } // 正在装它(行内已是「更新中」态)
|
|
908
|
+
/* 冷静期行不得入队:点它必然撞门禁,并在豁免表存在遮蔽条时连累整个 profile
|
|
909
|
+
的后续安装(实测)。标签本身也不可点击,这里是第二道闸。 */
|
|
910
|
+
var grow = findRow(name);
|
|
911
|
+
if (grow && grow.state === "cooling") { return; }
|
|
653
912
|
if (busyName) {
|
|
654
913
|
if (queue.indexOf(name) < 0) { queue.push(name); setRow(name, { state: "queuing" }); }
|
|
655
914
|
return;
|
|
@@ -687,11 +946,11 @@ window.__ModuleLoader__.load({
|
|
|
687
946
|
finish("unknown", "更新超时(240 秒未结束);结果未知,请点「重新检查」确认");
|
|
688
947
|
try { var pm2 = pickService(null, "remote.pluginManager"); if (pm2 && pm2.cancelInstall && busyReq) { pm2.cancelInstall(busyReq); } } catch (e) {}
|
|
689
948
|
}, 240000);
|
|
690
|
-
function finish(kind, why) {
|
|
949
|
+
function finish(kind, why, meta) {
|
|
691
950
|
if (settled) { return; }
|
|
692
951
|
settled = true;
|
|
693
952
|
clearTimeout(watchdog);
|
|
694
|
-
finishOne(name, kind, why || "");
|
|
953
|
+
finishOne(name, kind, why || "", meta);
|
|
695
954
|
}
|
|
696
955
|
function settle(st) {
|
|
697
956
|
if (!st || typeof st !== "object") { finish("unknown", "安装未返回可判定的结果"); return; }
|
|
@@ -700,7 +959,18 @@ window.__ModuleLoader__.load({
|
|
|
700
959
|
if (app === "restart-required") { finish("applied", "已应用,重启桌面端后生效"); return; }
|
|
701
960
|
if (app === "cancelled") { finish("cancelled", "已取消"); return; }
|
|
702
961
|
if (app === "overridden") { finish("failed", "已被另一次安装覆盖,请重试"); return; }
|
|
703
|
-
if (app === "failed") {
|
|
962
|
+
if (app === "failed") {
|
|
963
|
+
/* 成熟期违规不是「更新失败」:落 cooling,并把反推出来的窗口记进会话缓存
|
|
964
|
+
(预判复用);那条长报错不渲染,改由 cooling 的可展开详情承担。 */
|
|
965
|
+
var cool = coolingFromResult(st, Date.now(), policyWindowMs, ERROR_CODE_TEXT);
|
|
966
|
+
if (cool) {
|
|
967
|
+
if (isFinite(cool.windowMs) && cool.windowMs > 0) { policyWindowMs = cool.windowMs; }
|
|
968
|
+
finish("cooling", "", cool);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
finish("failed", failureText(st, ERROR_CODE_TEXT));
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
704
974
|
finish("unknown", "安装返回了无法识别的结果(application=" + String(app) + ")");
|
|
705
975
|
}
|
|
706
976
|
function lostReply(res) {
|
|
@@ -715,9 +985,11 @@ window.__ModuleLoader__.load({
|
|
|
715
985
|
finish("unknown", lostReplyText(res) + ";恢复结果失败,请点「重新检查」确认");
|
|
716
986
|
});
|
|
717
987
|
}
|
|
718
|
-
/* 安装 spec 用面板已解析的精确版本(`name@版本`),不用 @latest
|
|
719
|
-
minimumReleaseAge
|
|
720
|
-
|
|
988
|
+
/* 安装 spec 用面板已解析的精确版本(`name@版本`),不用 @latest —— 装的就是
|
|
989
|
+
面板显示的那个版本。注意它**不会**因此豁免 minimumReleaseAge:门禁在解析
|
|
990
|
+
之前校验 lockfile,窗口内的版本照样整体中止整个 profile 的安装(豁免只能靠
|
|
991
|
+
profile 的 minimumReleaseAgeExclude,那张表由 host 半边归并成每包一条)。
|
|
992
|
+
行内没解析过则现场查 registry。 */
|
|
721
993
|
var specPromise = busyLatest
|
|
722
994
|
? Promise.resolve(name + "@" + busyLatest)
|
|
723
995
|
: fetchLatest(name)
|
|
@@ -734,10 +1006,11 @@ window.__ModuleLoader__.load({
|
|
|
734
1006
|
}
|
|
735
1007
|
|
|
736
1008
|
/* 终态落定:回读清单核对真实版本(registry 解析与 pnpm 实际安装可能不一致,
|
|
737
|
-
如镜像源),再按 kind 决定文案。kind: applied | failed | cancelled |
|
|
1009
|
+
如镜像源),再按 kind 决定文案。kind: applied | failed | cancelled | cooling
|
|
1010
|
+
| unknown;cooling 只由 settle 的成熟期违规分支给出(meta 带成熟时刻与详情)。
|
|
738
1011
|
回读只用 pluginManager.listBundles(pluginInventory 只有 list(),没有
|
|
739
1012
|
listBundles);装完瞬间清单可能短暂不可用,重试 3 次(1.5s 间隔)再下结论。 */
|
|
740
|
-
function finishOne(name, kind, why) {
|
|
1013
|
+
function finishOne(name, kind, why, meta) {
|
|
741
1014
|
var before = busyBefore;
|
|
742
1015
|
var latest = busyLatest;
|
|
743
1016
|
var pm = pickService(null, "remote.pluginManager");
|
|
@@ -749,10 +1022,21 @@ window.__ModuleLoader__.load({
|
|
|
749
1022
|
}
|
|
750
1023
|
function judge(ver) {
|
|
751
1024
|
if (ver && ver !== before) {
|
|
752
|
-
setRow(name, { version: ver, state: "current", latest: ver, why: "" });
|
|
1025
|
+
setRow(name, { version: ver, state: "current", latest: ver, why: "", publishedAt: NaN, matureAt: NaN, detail: "" });
|
|
1026
|
+
/* 安装真的落地了:顺手让 host 半边归并豁免表,免得陈旧条遮蔽下一次安装 */
|
|
1027
|
+
normalizeExcludes();
|
|
753
1028
|
} else if (kind === "cancelled") {
|
|
754
1029
|
/* 取消不是失败:该行仍有新版本,退回可再次更新的状态。 */
|
|
755
1030
|
setRow(name, { state: "update", latest: latest, why: "" });
|
|
1031
|
+
} else if (kind === "cooling") {
|
|
1032
|
+
/* 门禁在解析前整体中止了这次安装:落 cooling(不是 fail),也不贴那串长报错 */
|
|
1033
|
+
var m = meta || {};
|
|
1034
|
+
var patch = {
|
|
1035
|
+
state: "cooling", why: "", detail: m.detail || "",
|
|
1036
|
+
publishedAt: m.publishedAt, matureAt: m.matureAt
|
|
1037
|
+
};
|
|
1038
|
+
if (latest || m.version) { patch.latest = latest || m.version; }
|
|
1039
|
+
setRow(name, patch);
|
|
756
1040
|
} else if (kind === "failed") {
|
|
757
1041
|
setRow(name, { state: "fail", why: why || "更新失败" });
|
|
758
1042
|
} else if (kind === "applied" && ver) {
|
|
@@ -833,6 +1117,7 @@ window.__ModuleLoader__.load({
|
|
|
833
1117
|
".rowwrap:last-child{border-bottom:0}" +
|
|
834
1118
|
".row{display:flex;align-items:center;gap:8px;padding:7px 12px}" +
|
|
835
1119
|
".why{padding:0 12px 8px;font-size:12px;line-height:1.45;opacity:.7;word-break:break-word;white-space:pre-wrap}" +
|
|
1120
|
+
".dtl{margin-top:4px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.28));font-size:11px;opacity:.62}" +
|
|
836
1121
|
".nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" +
|
|
837
1122
|
".ver{opacity:.6;font-size:12px;white-space:nowrap;font-variant-numeric:tabular-nums}" +
|
|
838
1123
|
".tag{font-size:11px;padding:1px 7px;border-radius:999px;white-space:nowrap;" +
|
|
@@ -842,6 +1127,7 @@ window.__ModuleLoader__.load({
|
|
|
842
1127
|
".tag.other{background:rgba(128,128,128,.14);opacity:.85}" +
|
|
843
1128
|
".tag.busy{opacity:.6}" +
|
|
844
1129
|
".tag.error{background:rgba(255,80,80,.14);color:#e07070;border-color:rgba(255,80,80,.28)}" +
|
|
1130
|
+
".tag.cooling{background:rgba(90,160,255,.16);color:#74b0ff;border-color:rgba(90,160,255,.34)}" +
|
|
845
1131
|
".tag.clickable{cursor:pointer}" +
|
|
846
1132
|
".tag.clickable:hover{filter:brightness(1.15)}" +
|
|
847
1133
|
".allbtn{cursor:pointer;font-size:12px;padding:2px 9px;border-radius:6px;flex:0 0 auto;" +
|
|
@@ -915,6 +1201,12 @@ window.__ModuleLoader__.load({
|
|
|
915
1201
|
rows.forEach(function (r) { if (r.state === "update" && queue.indexOf(r.name) < 0 && r.name !== busyName) { enqueue(r.name); } });
|
|
916
1202
|
return;
|
|
917
1203
|
}
|
|
1204
|
+
if (t.classList.contains("cooling")) {
|
|
1205
|
+
/* 冷静期标签:只展开 / 收起详情,绝不触发安装 */
|
|
1206
|
+
var nm0 = t.getAttribute("data-n");
|
|
1207
|
+
if (nm0) { revealedCooling[nm0] = !revealedCooling[nm0]; paint(); }
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
918
1210
|
if (t.classList.contains("act")) {
|
|
919
1211
|
var nm = t.getAttribute("data-n");
|
|
920
1212
|
if (nm) { enqueue(nm); }
|
|
@@ -949,6 +1241,7 @@ window.__ModuleLoader__.load({
|
|
|
949
1241
|
r.latest = v || "";
|
|
950
1242
|
r.state = v && cmpSemver(v, r.version) > 0 ? "update" : "current";
|
|
951
1243
|
emit();
|
|
1244
|
+
if (r.state === "update") { applyCoolingGuess(name); }
|
|
952
1245
|
}).catch(function (e) {
|
|
953
1246
|
setRow(name, e && e.notFound ? { state: notFoundState(cur), why: String(e.message) } : { state: "error", why: String(e && e.message || e) });
|
|
954
1247
|
});
|
|
@@ -960,7 +1253,8 @@ window.__ModuleLoader__.load({
|
|
|
960
1253
|
var TAG_TEXT = {
|
|
961
1254
|
checking: "比对中 …", current: "已是最新", update: "有新版本",
|
|
962
1255
|
local: "本地链接", remote: "Git 直装", unknown: "无法比对", error: "检查失败", queuing: "等待更新",
|
|
963
|
-
busy: "更新中 …", unchanged: "暂缓更新", unconfirmed: "已发起·未确认", fail: "更新失败"
|
|
1256
|
+
busy: "更新中 …", unchanged: "暂缓更新", unconfirmed: "已发起·未确认", fail: "更新失败",
|
|
1257
|
+
cooling: "冷静期中"
|
|
964
1258
|
};
|
|
965
1259
|
function tagHtml(r) {
|
|
966
1260
|
var cls = "tag other", act = "", title = "";
|
|
@@ -972,6 +1266,8 @@ window.__ModuleLoader__.load({
|
|
|
972
1266
|
else if (r.state === "unknown") { title = " title=\"" + esc(r.why || "插件清单未提供已装版本") + ",因此无法与 registry 比对\""; }
|
|
973
1267
|
else if (r.state === "queuing") { cls = "tag other cancel clickable"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"点击取消排队\""; }
|
|
974
1268
|
else if (r.state === "busy") { cls = "tag other cancel clickable"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"" + esc(r.why ? r.why + ";" : "") + "点击取消本次更新\""; }
|
|
1269
|
+
/* cooling:不可点击安装(点它必然撞门禁、还会连累整个 profile),只切换详情 */
|
|
1270
|
+
else if (r.state === "cooling") { cls = "tag cooling clickable"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"处于供应链冷静期(minimumReleaseAge);点击展开/收起详情,不会触发安装\""; }
|
|
975
1271
|
else if (r.state === "fail") { cls = "tag error act clickable"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"" + esc(r.why || "更新失败") + "(点击重试)\""; }
|
|
976
1272
|
else if (r.state === "unchanged") { cls = "tag update act clickable"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"" + esc(r.why || "") + "(点击重试)\""; }
|
|
977
1273
|
else if (r.state === "unconfirmed") { cls = "tag other reverify clickable"; title = " title=\"" + esc(r.why || "") + "(点击重查)\""; }
|
|
@@ -987,14 +1283,21 @@ window.__ModuleLoader__.load({
|
|
|
987
1283
|
body = "<div class=\"msg\">" + (checking ? "正在读取插件清单…" : "点上方「重新检查」开始比对") + "</div>";
|
|
988
1284
|
} else {
|
|
989
1285
|
body = rows.map(function (r) {
|
|
990
|
-
var showLatest = r.state === "update" || r.state === "unchanged" || r.state === "fail";
|
|
1286
|
+
var showLatest = r.state === "update" || r.state === "unchanged" || r.state === "fail" || r.state === "cooling";
|
|
991
1287
|
var ver = r.latest && showLatest ? r.version + " → " + r.latest : r.version;
|
|
992
|
-
|
|
993
|
-
|
|
1288
|
+
/* cooling 的副标题是现算的(绝对成熟时刻 + 相对剩余时间),不在 r.why 里 */
|
|
1289
|
+
var why = r.state === "cooling"
|
|
1290
|
+
? coolingWhyText({ version: r.latest || r.version, windowMs: policyWindowMs, publishedAt: r.publishedAt, matureAt: r.matureAt, nowMs: Date.now() })
|
|
1291
|
+
: r.why;
|
|
1292
|
+
var sub = why && (r.state === "fail" || r.state === "unchanged" || r.state === "unconfirmed" || r.state === "error" || r.state === "cooling")
|
|
1293
|
+
? "<div class=\"why\">" + esc(why) + "</div>" : "";
|
|
1294
|
+
/* 详情只在点开 cooling 标签后渲染:失败/冷静期的那条完整诊断默认不占第一屏 */
|
|
1295
|
+
var dtl = r.state === "cooling" && revealedCooling[r.name] && r.detail
|
|
1296
|
+
? "<div class=\"why dtl\">" + esc(r.detail) + "</div>" : "";
|
|
994
1297
|
return "<div class=\"rowwrap\"><div class=\"row\">" +
|
|
995
1298
|
"<span class=\"nm\" title=\"" + esc(r.name) + "\">" + esc(r.name) + "</span>" +
|
|
996
1299
|
"<span class=\"ver\" title=\"" + esc(ver) + "\">" + esc(ver) + "</span>" +
|
|
997
|
-
tagHtml(r) + "</div>" + sub + "</div>";
|
|
1300
|
+
tagHtml(r) + "</div>" + sub + dtl + "</div>";
|
|
998
1301
|
}).join("");
|
|
999
1302
|
}
|
|
1000
1303
|
var html = "<div class=\"hd\"><b>插件更新 · " + (busyName ? "正在更新 " + esc(busyName) + " …" : (checking ? "检查中…" : (rows.length ? upd + " 个插件有新版本" : "插件更新检查"))) + "</b>" +
|
package/lib/index.js
CHANGED
|
@@ -1,99 +1,317 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-ui-tweaks host half.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ui-tweaks host half.
|
|
3
|
+
*
|
|
4
|
+
* 两条回环路由:
|
|
5
|
+
* - GET /api/ui-tweaks/local-plugins:返回当前 profile package.json
|
|
6
|
+
* dependencies 里非 registry 直装(link:/file:/git+/github:/http)的包名清单。
|
|
7
|
+
* 浏览器半区的更新面板据此把这些插件标成「本地链接」或「Git 直装」、跳过
|
|
8
|
+
* registry 比对 —— listBundles 不透传安装来源,而「npm 上恰好也发布过」的
|
|
9
|
+
* 非 registry 源(如 @nimoxie/dsh-model-organizer)在客户端侧没有别的办法认。
|
|
10
|
+
* - POST /api/ui-tweaks/normalize-excludes:手动触发一次下面的归并,回
|
|
11
|
+
* {ok, changed, kept, removed};插件加载时也会静默跑一次。
|
|
12
|
+
*
|
|
13
|
+
* 写操作(全文件只此一处):归并 profile 的 pnpm-workspace.yaml 里
|
|
14
|
+
* minimumReleaseAgeExclude 序列。为什么必须收敛:
|
|
15
|
+
* DSH 给自带 pnpm 11.7.0 注入供应链策略 minimumReleaseAge=1440(分钟,不在任何配置文件里),
|
|
16
|
+
* pnpm 在解析之前先校验磁盘上现有的 lockfile,任一 entry 未成熟即整体中止安装并报
|
|
17
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION(exit 1);而该白名单按包名匹配,同一个包名出现
|
|
18
|
+
* 多条时「文件中靠前的那条生效」。于是同包重复/陈旧条目会静默抵消掉真正需要的豁免
|
|
19
|
+
* (例:dsh-context@0.61.0 陈旧条排在 dsh-context@0.69.0 前面,后者从未生效),
|
|
20
|
+
* 之后 profile 的每一次安装都失败;而一次成功的「未成熟版本」安装又会把新条目追加到
|
|
21
|
+
* 末尾,让旧条重新变成靠前那条 —— 紧接着的下一次安装立刻 FAIL。所以这必须常态化自动收敛。
|
|
22
|
+
* 安全约束:只改这一个序列,其余字节逐字节保持原样;写前备份
|
|
23
|
+
* pnpm-workspace.yaml.bak-<时间戳>;同目录临时文件 + renameSync 原子替换;写后重读校验;
|
|
24
|
+
* 无变化不写文件;幂等;结果永远是原条目的子集(绝不比现状更宽松);陈旧剪除只针对
|
|
25
|
+
* profile dependencies 里的受管包名,重复折叠适用于任何包名;裸名(不带 @版本)豁免该包
|
|
26
|
+
* 全部版本,永不视为陈旧。整个过程不外抛异常。仅回环访问放行。
|
|
27
|
+
*/
|
|
28
|
+
import { readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
export const name = "ui-tweaks";
|
|
34
|
+
export const inject = ["webServer"];
|
|
35
|
+
|
|
36
|
+
const SELF = "@nimoxie/dsh-ui-tweaks";
|
|
37
|
+
const LOCAL_SPEC_RE = /^(link:|file:)/; // 本地路径(link:→按包名查 node_modules)
|
|
38
|
+
const REMOTE_SPEC_RE = /^(git[+:@]|github:|git:|http)/; // Git 仓库 / URL 直装
|
|
39
|
+
const LOOPBACK_RE = /^(127\.|::1$|::ffff:127\.)/;
|
|
40
|
+
const EXCLUDE_KEY = "minimumReleaseAgeExclude";
|
|
41
|
+
const WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
42
|
+
|
|
43
|
+
function readJson(p) {
|
|
44
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasUs(m) {
|
|
48
|
+
return !!(m && m.dsh && m.dsh.profile && Array.isArray(m.dsh.profile.bundles) && m.dsh.profile.bundles.includes(SELF));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findProfileDir() {
|
|
52
|
+
// ① npm 直装:包物理位于 profile/node_modules 内,向上走必经 profile 根
|
|
53
|
+
// (profile 根 package.json 的判据是 dsh.profile.bundles 且包含本插件)。
|
|
54
|
+
let dir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
55
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
56
|
+
const m = readJson(join(dir, "package.json"));
|
|
57
|
+
if (hasUs(m)) { return dir; }
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir) { break; }
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
// ② link: 软链安装:Node 默认把 ESM 模块解析到真实路径,import.meta.url
|
|
63
|
+
// 指向 profile 外的仓库目录,①必然失败。退回扫描 profiles/*:
|
|
64
|
+
// DSH_HOME 环境变量 → 用户主目录默认 .dsh(dsh 未设环境变量时的标准位置)。
|
|
65
|
+
// 多个 profile 都装了本插件时按 host 进程形态二选一
|
|
66
|
+
// (桌面 host 跑在 ELECTRON_RUN_AS_NODE=1 的 Electron Node 下)。
|
|
67
|
+
const homes = [process.env.DSH_HOME, join(homedir(), ".dsh")].filter(Boolean);
|
|
68
|
+
let hits = [];
|
|
69
|
+
for (const home of homes) {
|
|
70
|
+
try {
|
|
71
|
+
for (const entry of readdirSync(join(home, "profiles"))) {
|
|
72
|
+
const dir2 = join(home, "profiles", entry);
|
|
73
|
+
if (hasUs(readJson(join(dir2, "package.json")))) { hits.push(dir2); }
|
|
74
|
+
}
|
|
75
|
+
} catch { /* 该 home 不存在或不可读,试下一个 */ }
|
|
76
|
+
if (hits.length) { break; }
|
|
77
|
+
}
|
|
78
|
+
if (hits.length === 0) { return null; }
|
|
79
|
+
if (hits.length === 1) { return hits[0]; }
|
|
80
|
+
const wanted = process.env.ELECTRON_RUN_AS_NODE === "1" ? "desktop" : "web";
|
|
81
|
+
return hits.find((d) => new RegExp(wanted + "$").test(d)) || hits[0];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- minimumReleaseAgeExclude 归并 ----------
|
|
85
|
+
|
|
86
|
+
/** yaml 标量去引号:'x' / "x" → x,其余原样 */
|
|
87
|
+
function unquote(s) {
|
|
88
|
+
const m = /^(['"])([\s\S]*)\1$/.exec(s);
|
|
89
|
+
return m ? m[2] : s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 条目原文 → 裸值:去空白、去行尾注释、去引号 */
|
|
93
|
+
function entryValue(raw) {
|
|
94
|
+
const s = String(raw).trim();
|
|
95
|
+
const quoted = unquote(s);
|
|
96
|
+
if (quoted !== s) { return quoted; } // 整段就是带引号的标量
|
|
97
|
+
const i = s.indexOf(" #"); // 裸标量后跟注释
|
|
98
|
+
return i < 0 ? s : unquote(s.slice(0, i).trim());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 版本键里的 'name@version' → {name, version}(scoped 与裸名都要能分) */
|
|
102
|
+
function splitNameVersion(s) {
|
|
103
|
+
const at = s.startsWith("@") ? s.indexOf("@", 1) : s.indexOf("@");
|
|
104
|
+
if (at <= 0) { return { name: s, version: null }; }
|
|
105
|
+
return { name: s.slice(0, at), version: s.slice(at + 1) || null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** '1.2.3(peer@x)' → '1.2.3';link:/file:/workspace: 协议没有版本概念 → undefined */
|
|
109
|
+
function cleanVersion(v) {
|
|
110
|
+
const s = String(v || "").trim().replace(/\([\s\S]*$/, "").replace(/^['"]|['"]$/g, "");
|
|
111
|
+
if (!s || /^(link:|file:|workspace:)/.test(s)) { return undefined; }
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 从 pnpm-lock.yaml 取「包名 → 锁定版本」。
|
|
117
|
+
* importers 段(profile 自己的 dependencies)优先,packages 段兜底;
|
|
118
|
+
* link:/file:/workspace: 这类没有发布版本的条目不进表(查询得到 undefined = 未锁定)。
|
|
119
|
+
*/
|
|
120
|
+
function parseLockVersions(text) {
|
|
121
|
+
const fromImporters = new Map();
|
|
122
|
+
const fromPackages = new Map();
|
|
123
|
+
let section = null;
|
|
124
|
+
let inImporterDeps = false;
|
|
125
|
+
let pendingName = null;
|
|
126
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
127
|
+
const top = /^([A-Za-z][\w-]*):\s*$/.exec(line); // 缩进 0 的顶层段落
|
|
128
|
+
if (top) { section = top[1]; inImporterDeps = false; pendingName = null; continue; }
|
|
129
|
+
if (section === "importers") {
|
|
130
|
+
if (/^ {4}(?:dependencies|devDependencies|optionalDependencies):\s*$/.test(line)) { inImporterDeps = true; continue; }
|
|
131
|
+
if (/^ {2,4}\S/.test(line)) { inImporterDeps = false; continue; } // 换 importer / 换字段
|
|
132
|
+
if (inImporterDeps) {
|
|
133
|
+
const k = /^ {6}(\S[\s\S]*?):\s*$/.exec(line);
|
|
134
|
+
if (k) { pendingName = unquote(k[1]); continue; }
|
|
135
|
+
const v = /^ {8}version:\s*(\S+)\s*$/.exec(line);
|
|
136
|
+
if (v && pendingName) {
|
|
137
|
+
const ver = cleanVersion(v[1]);
|
|
138
|
+
if (ver) { fromImporters.set(pendingName, ver); }
|
|
139
|
+
pendingName = null;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else if (section === "packages") {
|
|
144
|
+
const k = /^ {2}(\S[\s\S]*?):\s*$/.exec(line); // 'name@version':
|
|
145
|
+
if (k) {
|
|
146
|
+
const p = splitNameVersion(unquote(k[1]));
|
|
147
|
+
const ver = p.version && cleanVersion(p.version);
|
|
148
|
+
if (ver) { fromPackages.set(p.name, ver); }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const out = new Map(fromPackages);
|
|
153
|
+
for (const [k, v] of fromImporters) { out.set(k, v); }
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 归并 profile pnpm-workspace.yaml 的 minimumReleaseAgeExclude 序列。
|
|
159
|
+
* 只重排这一段的条目行,其余字节逐字节保留;无变化不写盘。
|
|
160
|
+
* 任何异常都被吞掉,返回 {ok:false, code}。
|
|
161
|
+
*/
|
|
162
|
+
export function normalizeReleaseAgeExcludes() {
|
|
163
|
+
let backup = null;
|
|
164
|
+
try {
|
|
165
|
+
const dir = findProfileDir();
|
|
166
|
+
if (!dir) { return { ok: false, code: "profile-not-found" }; }
|
|
167
|
+
const wsPath = join(dir, WORKSPACE_FILE);
|
|
168
|
+
let raw;
|
|
169
|
+
try { raw = readFileSync(wsPath, "utf8"); }
|
|
170
|
+
catch (e) { return { ok: false, code: "workspace-unreadable: " + String((e && e.code) || e) }; }
|
|
171
|
+
|
|
172
|
+
// 受管包名:profile dependencies 的键
|
|
173
|
+
const manifest = readJson(join(dir, "package.json"));
|
|
174
|
+
const managed = new Set(Object.keys((manifest && manifest.dependencies) || {}));
|
|
175
|
+
|
|
176
|
+
// 锁定版本:lockfile 不可读/不存在时留空表,陈旧剪除据此退化为「无版本可依」
|
|
177
|
+
let locked = new Map();
|
|
178
|
+
let lockKnown = false;
|
|
179
|
+
try { locked = parseLockVersions(readFileSync(join(dir, "pnpm-lock.yaml"), "utf8")); lockKnown = true; } catch { /* 无 lockfile */ }
|
|
180
|
+
|
|
181
|
+
const lines = String(raw).split(/(?<=\n)/); // 每行带着自己的换行符,join 即还原
|
|
182
|
+
const keyRe = new RegExp("^" + EXCLUDE_KEY + "\\s*:\\s*(?:#[\\s\\S]*)?$");
|
|
183
|
+
let keyLine = -1;
|
|
184
|
+
for (let i = 0; i < lines.length; i++) {
|
|
185
|
+
if (keyRe.test(lines[i].replace(/\r?\n$/, ""))) { keyLine = i; break; }
|
|
186
|
+
}
|
|
187
|
+
// 没有这个键不是错误:profile 本来就没有待归并的条目(或已被清理干净),按「无变化」回。
|
|
188
|
+
if (keyLine < 0) { return { ok: true, changed: false, kept: [], removed: [], path: wsPath }; }
|
|
189
|
+
|
|
190
|
+
const entries = []; // {idx, raw(line), name, version}
|
|
191
|
+
for (let i = keyLine + 1; i < lines.length; i++) {
|
|
192
|
+
const body = lines[i].replace(/\r?\n$/, "");
|
|
193
|
+
const m = /^(\s*)-\s+(\S[\s\S]*)$/.exec(body);
|
|
194
|
+
if (!m || m[1].length === 0) { break; } // 序列条目必须缩进,否则该块已结束
|
|
195
|
+
const value = entryValue(m[2]);
|
|
196
|
+
const nv = splitNameVersion(value);
|
|
197
|
+
entries.push({ idx: i, line: lines[i], value, name: nv.name, version: nv.version });
|
|
198
|
+
}
|
|
199
|
+
// 键在但一条条目都没有,同样按「无变化」回(客户端是 fire-and-forget,500 只会变成日志噪声)。
|
|
200
|
+
if (entries.length === 0) { return { ok: true, changed: false, kept: [], removed: [], path: wsPath }; }
|
|
201
|
+
|
|
202
|
+
const groups = new Map(); // name → [entry…](保序)
|
|
203
|
+
for (const e of entries) {
|
|
204
|
+
if (!groups.has(e.name)) { groups.set(e.name, []); }
|
|
205
|
+
groups.get(e.name).push(e);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const keep = new Set(); // 保留的条目行号
|
|
209
|
+
const removed = [];
|
|
210
|
+
for (const [pkg, group] of groups) {
|
|
211
|
+
if (group.length >= 2) {
|
|
212
|
+
// 重复折叠:优先保留「版本 == lockfile 锁定版本」的那条,否则保留文件中第一条
|
|
213
|
+
// (与 pnpm「靠前者生效」的现行语义一致,且结果永远是原并集的子集)。
|
|
214
|
+
const want = locked.get(pkg);
|
|
215
|
+
const chosen = (want && group.find((e) => e.version === want)) || group[0];
|
|
216
|
+
keep.add(chosen.idx);
|
|
217
|
+
for (const e of group) { if (e !== chosen) { removed.push(e.value); } }
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const e = group[0];
|
|
221
|
+
// 陈旧剪除:只碰受管包名;裸名豁免全部版本,永不视为陈旧。
|
|
222
|
+
// lockfile 整个缺失时不剪除(此时「锁定版本」无从谈起,剪掉受管条可能把
|
|
223
|
+
// 尚未安装的 profile 卡死 —— 宁可不动,提交集方向也依然安全)。
|
|
224
|
+
if (lockKnown && managed.has(pkg) && e.version !== null && e.version !== locked.get(pkg)) {
|
|
225
|
+
removed.push(e.value);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
keep.add(e.idx);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entryIdx = new Set(entries.map((e) => e.idx));
|
|
232
|
+
const keptInOrder = entries.filter((e) => keep.has(e.idx)).map((e) => e.value);
|
|
233
|
+
const newRaw = lines.filter((line, i) => !entryIdx.has(i) || keep.has(i)).join("");
|
|
234
|
+
|
|
235
|
+
if (newRaw === raw) { return { ok: true, changed: false, kept: keptInOrder, removed: [], path: wsPath }; }
|
|
236
|
+
|
|
237
|
+
// ① 写前备份(同目录,时间戳后缀)
|
|
238
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
239
|
+
backup = wsPath + ".bak-" + stamp;
|
|
240
|
+
writeFileSync(backup, raw, "utf8");
|
|
241
|
+
// ② 原子替换:同目录临时文件 + renameSync
|
|
242
|
+
const tmp = join(dir, "." + WORKSPACE_FILE + ".tmp-" + process.pid + "-" + Date.now());
|
|
243
|
+
try {
|
|
244
|
+
writeFileSync(tmp, newRaw, "utf8");
|
|
245
|
+
renameSync(tmp, wsPath);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
try { unlinkSync(tmp); } catch { /* 临时文件可能未创建 */ }
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
// ③ 写后重读校验
|
|
251
|
+
const after = readFileSync(wsPath, "utf8");
|
|
252
|
+
if (after !== newRaw) { return { ok: false, code: "verify-mismatch", path: wsPath, backup }; }
|
|
253
|
+
|
|
254
|
+
return { ok: true, changed: true, kept: keptInOrder, removed, path: wsPath, backup };
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return { ok: false, code: String((e && e.message) || e) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function apply(ctx) {
|
|
261
|
+
const respond = (res) => (code, body) => {
|
|
262
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
263
|
+
res.end(body);
|
|
264
|
+
};
|
|
265
|
+
const isLoopback = (req) => LOOPBACK_RE.test(String((req.socket && req.socket.remoteAddress) || ""));
|
|
266
|
+
|
|
267
|
+
const routes = [
|
|
268
|
+
{
|
|
269
|
+
kind: "exact",
|
|
270
|
+
path: "/api/ui-tweaks/local-plugins",
|
|
271
|
+
handler: (req, res) => {
|
|
272
|
+
const done = respond(res);
|
|
273
|
+
try {
|
|
274
|
+
if (!isLoopback(req)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
|
|
275
|
+
if (req.method !== "GET") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
|
|
276
|
+
const dir = findProfileDir();
|
|
277
|
+
const manifest = dir ? readJson(join(dir, "package.json")) : null;
|
|
278
|
+
const deps = (manifest && manifest.dependencies) || {};
|
|
279
|
+
const local = []; // link:/file: —— 本地路径
|
|
280
|
+
const remote = []; // git/github/http —— 仓库或 URL 直装
|
|
281
|
+
for (const [n, spec] of Object.entries(deps)) {
|
|
282
|
+
const s = String(spec);
|
|
283
|
+
if (LOCAL_SPEC_RE.test(s)) { local.push(n); }
|
|
284
|
+
else if (REMOTE_SPEC_RE.test(s)) { remote.push(n); }
|
|
285
|
+
}
|
|
286
|
+
done(200, JSON.stringify({ ok: true, local, remote, profile: (manifest && manifest.name) || "" }));
|
|
287
|
+
} catch (e) {
|
|
288
|
+
done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) }));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
kind: "exact",
|
|
294
|
+
path: "/api/ui-tweaks/normalize-excludes",
|
|
295
|
+
handler: (req, res) => {
|
|
296
|
+
const done = respond(res);
|
|
297
|
+
try {
|
|
298
|
+
if (!isLoopback(req)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
|
|
299
|
+
if (req.method !== "POST") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
|
|
300
|
+
const r = normalizeReleaseAgeExcludes();
|
|
301
|
+
if (!r.ok) { done(500, JSON.stringify({ ok: false, code: r.code })); return; }
|
|
302
|
+
done(200, JSON.stringify({ ok: true, changed: r.changed, kept: r.kept, removed: r.removed }));
|
|
303
|
+
} catch (e) {
|
|
304
|
+
done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
ctx.effect(() => {
|
|
311
|
+
const disposes = routes.map((r) => ctx.webServer.register(r));
|
|
312
|
+
return () => { for (const d of disposes) { try { d(); } catch { /* 已释放 */ } } };
|
|
313
|
+
}, "ui-tweaks: loopback routes");
|
|
314
|
+
|
|
315
|
+
// 加载时静默收敛一次(fire-and-forget:异常绝不影响插件加载)
|
|
316
|
+
try { normalizeReleaseAgeExcludes(); } catch { /* 归并自身已吞异常,这里只兜底 */ }
|
|
317
|
+
}
|