Repository navigation
Releases: Winnie-0721/dsh-plugin-market
Release list
v1.3.2
-
fix(catalog):目录源全线抓取失败(502
catalog-unavailable)—— npm 解压上限 8 MiB 被上游新增文件顶穿。现象:市场整个打不开数据,三个源依次失败,界面报「目录源抓取数据失败」:
npm registry.npmmirror.com : 读取 tarball 失败:Cannot create a Buffer larger than 8388608 bytes npm registry.npmjs.org : 读取 tarball 失败:Cannot create a Buffer larger than 8388608 bytes awesome-dsh-plugin.com : 读取响应体失败:The operation was aborted due to timeout不是网络问题,是上限被顶穿。
MAX_TARBALL_BYTES量的是整个 tar 解压后的总长,
不是我们要取的那一个文件:项 值 tarball 压缩后 2,363,900 B 解压后 tar 总长 9,532,928 B(9.09 MiB) ← 超过 8 MiB 我们要的 package/plugins.json7,198,658 B(6.87 MiB)✅ 本身没超 顶穿它的是同一个包里我们从不读的
package/index.json(2,320,845 B)。
上游 2026-10-182026.1018.7728把fileCount从 4 加到 5、塞进这个文件,
解压总量从 7.21 MiB 一步跳到 9.53 MiB:10-16 5,796,742 files=4 10-17 6,213,609 files=4 10-18 7,206,746 files=4 ← 旧版尚可 10-18 9,528,364 files=5 ← latest,首次越线次生问题:npm 两条路一挂,官方 URL 兜底根本指望不上——实测该站 10.9 KB/s,
7.2 MB 要 ~11 分钟,而OFFICIAL_URL_TIMEOUT_MS只有 30s。三个源全灭。逻辑倒挂:压缩侧上限
MAX_NPM_TARBALL_BYTES = 16 MiB,解压侧却是 8 MiB ——
允许下载 16 MiB 的压缩包,却只准解出 8 MiB,自相矛盾。修复:
MAX_TARBALL_BYTES8 MiB → 16 MiB,与压缩侧上限同档。
炸弹防护意图完全不变(32 MiB 测试炸弹仍被maxOutputLength挡在分配之前),
只是把一个按「当时 5 MB 目录」拍的数,改成与压缩侧同一量级。实测(真跑
cache.ensure()):修复前 修复后 结果 502 catalog-unavailable,42.2sok,1.19s 条目 — 5786( count与plugins.length一致)回归锚(
verify/robustness.test.mjs):钉住MAX_TARBALL_BYTES === 16 MiB,
并加不变式MAX_TARBALL_BYTES >= MAX_NPM_TARBALL_BYTES—— 数值被改回去(或压缩侧
再被调小到低于解压侧)时测试必须失败,而不是等线上复现。
已用变异验证锚不是死代码:把常量改回 8 MiB,基线 9/9 绿、变异 8/9 且
失败在该条断言上(+ 8388608 / - 16777216)。上限再被逼近时的正确解法是改流式解压、读到目标条目即停,而不是继续抬这个数——
上限越高,同步gunzipSync对宿主事件循环的代价越大。已写入docs/API-CONTRACT.md。
- versionName
1.3.2/ versionCode10302/ build+132.c7864f3 - 安装:
dsh plugin --profile web add <本页附件 deepseek-harness-market-1.3.2.tgz>(发布到 npm 后可直接add deepseek-harness-market) - 回退 / 旧版:从本页附件下载对应版本的 tgz
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md
v1.3.1
-
fix(ci):npm 发布永远 ENEEDAUTH —— 由
workflow_call复用改为「publish-npm.yml 自己被标签触发」。v1.3.0 的 GitHub Release 与 tarball 都正常生成了,只有 npm 那步失败:
npm error code ENEEDAUTH npm error need auth This command requires you to be logged in to https://registry.npmjs.org/根因(npm 官方文档《Trusted publishing》原文):
Some GitHub Actions workflows use
workflow_callto invoke other workflows that run
npm publish... When this happens, validation checks the calling workflow's name
instead of the workflow that actually contains the publish command.也就是说 npm 服务端校验的是调用方。而 v1.2.0 记下的结论是「用
uses:复用
publish-npm.yml,令牌里job_workflow_ref就是它」——那只是 OIDC 令牌字段,
不等于 npm 按它校验。v1.3.0 把这条错误结论的代价演得很清楚:自检打印
job_workflow_ref = .../publish-npm.yml@refs/tags/v1.3.0、判定「身份正确」,
紧接着发布仍然 ENEEDAUTH,因为 npm 看的是workflow_ref = .../pack-release.yml。历史对照(同一份代码,只差触发方式):
版本 触发 调用方 结果 v1.2.0 手动 workflow_dispatch直接跑 publish-npm.ymlpublish-npm.yml ✅ v1.3.0 标签 → pack-release.yml → workflow_callpack-release.yml ❌ 修法:
publish-npm.yml自己监听push: tags: ['v*'],与pack-release.yml
并列触发、互不调用(一个发 GitHub Release,一个发 npm);删掉pack-release.yml
里那个注定失败的publish-npmjob,并去掉workflow_call。
发布实现仍然只有一份(就在 publish-npm.yml),npm 看到的调用方永远是它自己。顺带修掉那个「假绿灯」自检:原来的自检只比对
job_workflow_ref,在workflow_call
下必然通过,于是自检过了、发布却失败——这正是 v1.3.0 排查绕弯的原因。现在要求
job_workflow_ref和workflow_ref都指向 publish-npm.yml。门禁同步纠正:
release-workflow.test.mjs原来把「必须用uses:复用」钉成底线
(14 条里有 3 条在守这个错误结论),现在改为反向断言:任何 workflow 都不得
uses:/workflow_callpublish-npm.yml,且 publish-npm.yml 必须自己监听标签。
门禁里的这些断言只查生效行、不查注释——pack-release.yml的注释里刻意写着
「本文件也不去调用 publish-npm.yml」来解释原因,全文正则会把这句解释判成违规。
- versionName
1.3.1/ versionCode10301/ build+128.0363650 - 安装:
dsh plugin --profile web add <本页附件 deepseek-harness-market-1.3.1.tgz>(发布到 npm 后可直接add deepseek-harness-market) - 回退 / 旧版:从本页附件下载对应版本的 tgz
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md
v1.3.0
-
fix:更新永远失败(「宿主拒绝了这个操作」)——更新改走 tarball 形态,绕开宿主的同名拦截。
用户报的:点「更新到 x.y.z」→ 宿主返回
already-installed,界面只说「宿主拒绝了这个操作。」。
实测两个可更新包(dsh-web-search-free1.8.0→1.8.1、dsh-mobile0.6.10→0.6.11)都是这个结果。根因(从 app.asar 抠出宿主管家源码确认,不是猜):
PluginManager.installBundle里有这行守卫const known = specPackageName(spec, before, dir); if (hmr !== void 0 && known !== void 0 && Object.hasOwn(before, known)) throw new ManagementFailure('already-installed');
而
specPackageName对 registry 形态的 spec 直接返回包名、完全不看版本。于是
pnpm add name@1.8.1这种升级写法,只要包已在 dependencies 里就必然被拒。宿主也没有
任何 upgrade/update/allowReinstall 入口(方法表只有 installBundle / removeBundle /
waitForInstall / cancelInstall),removeBundle又会对正在加载的插件抛stop-profile
—— 所以「先卸再装」也不通,且中途会留下坏 profile。这不是能力问题,是入口被自己的守卫挡了:在用户 profile 上实测
pnpm add dsh-web-search-free@1.8.1 --lockfile-only→ 「✓ Lockfile passes supply-chain
policies」+ Done,而声明本来就是^1.8.0。修法:客户端在更新时把
kind:"update"带上;服务端在目录校验之后把它改写成
npm tarball URL(https://registry.npmjs.org/<name>/-/<base>-<ver>.tgz)。tarball 形态下
宿主那个known变成undefined,守卫不成立,pnpm 照常执行升级。安全边界(三条,
updateTarballSpec的注释里写清了):- 只在
kind === 'update'时改写,首次安装仍按目录给的 spec 走; - 必须在校验 spec 落在目录里之后,否则这里就成了任意包安装器;
- 版本必须像 semver 定值,
latest/range 原样返回(拼不出 tarball URL)。
新增
verify/update-path.test.mjs(8 条)钉住:改写函数存在且 scoped 包文件名正确、
只在 update 时改写、客户端带 kind、目录校验仍在且排在改写之前、非更新请求 spec 不变、
already-installed有可读文案。现场实测(_verify/probe-update-fixed.mjs,5/5 通过):
更新请求ok:true / application:applied / activation.versionMatches:true、
pnpm 输出+ dsh-web-search-free 1.8.1;对照组(不带 kind)仍被 already-installed 拦
——证明改写是必要条件,而不是宿主自己变好了。 - 只在
-
feat(ui):点开详情改成弹出居中对话框**(用户要求,参照会话清理的「确认删除」框)。**
形态演进,别再退回前两版:
- 内嵌在卡片里 → 被塞进 232px 网格窄列,截图/红线长句挤成一坨、整行被撑高(「展开太丑」)。
- 跨整行独立卡片 → 宽度够用了,但它在文档流里:展开会把下面的内容整体顶下去,
用户视线要跟着滚,关掉又跳回来。 - 现在:居中对话框 → 浮在页面上,列表位置完全不动,进出都不丢上下文。
形态与语义照
dsh-dialogue-cleanup的确认框:标题行(名字 + @owner + ×)、
可滚动内容区、底部操作行。语义上对齐官方 Modal 的做法(拷贝行为,不 require 包):- 不用原生
<dialog>、不 portal 到document.body:覆盖层position:fixed+
既有 z-index 约定(回顶 55 < 通知条 60 < 弹层 70)取胜。 - Esc 关闭(
repeat不重复触发);Tab / Shift+Tab 在卡片内循环(焦点陷阱);
打开时焦点落在 ×(安全出口),关掉后焦点还给打开它的那颗「详情」按钮。 - 点遮罩关闭,点卡片本身不关。关闭入口三处:×、底部「关闭详情」、Esc。
对话框里缩略图收到 180px(框宽 760px,原来 26vw 会算到 400px+,三张各占一行 16:10
把内容区撑到必须滚动,实测对话框高 743px)。用双类.dshpm-detailDialogBody .dshpm-shot
提高特异性——单类会被.dshpm-shot盖掉(这个坑 lightbox 的背景已经栽过一次)。catalog-fields的 4 条旧断言(跨行卡片形态)改写为对话框语义,并新增:
对话框不参与网格布局、内容区overflow:auto+min-height:0(flex 子项能滚的必要条件)、
对话框渲染在网格外面、焦点归还三层兜底。 -
fix:安装失败只说「宿主拒绝了这个操作」——12 个宿主错误码补齐三段式文案。
用户报的「还是安装失败」。实测真宿主(POST /plugin-market/install,装一个已装过的包):
{"ok":false,"application":"failed", "error":{"code":"already-installed","message":"宿主拒绝了这个操作。"}}也就是:这个包已经装过了,根本不是什么失败。但客户端
ERROR_PREFIXES里没有
already-installed,于是 errorCopy 走兜底分支,把宿主那句泛泛的 message 原样显示出来。这是系统性缺口,不是单个码。从
app.asar里枚举宿主管家真实会抛的 code,共 12 个:
already-installed/ambiguous-install/bundle-in-use/incompatible-version/
invalid-spec/management-required/not-bundle/not-removable/
operation-error/stale-approval/stop-profile/unknown-plugin。
市场文案表原本只覆盖 4 个,其余全落到「出错了 / 操作失败」这种什么也没说的话。
12 个全部补上 title / why / next 三段(中英各一套),每个都回答:发生了什么、为什么、下一步。以
already-installed为例,修前用户只看到「宿主拒绝了这个操作。」,修后是:**发生了什么:**这个插件已经装过了
**为什么:**宿主检查到目标依赖已经在 profile 里,于是拒绝了这次重复安装(这不是失败,什么都没坏)。
**现在怎么办:**去「已安装」页确认版本;要升级就点那一行的「更新到 x.y.z」。operation-error是个特例,两样都要(用户选定):它以前刻意不进表,就是为了让宿主的
诊断原文(EPERM: ...、ECONNRESET、pnpm 报错尾巴)直接占据 why——那是用户唯一能拿去搜的
线索。但那样 title 只说「出错了」、next 空着。现在它进表(有了 title/next),同时why 仍
优先用宿主诊断原文:给方向 + 给真实原因,两样都不丢。顺带修掉「服务端说明」重复一遍的毛病。原判据是
copy.message !== copy.title,太松:
宿主的「宿主拒绝了这个操作。」与 title 不同,于是又被当成「服务端说明」渲染一行——三段式
刚说完「这不是失败,什么都没坏」,下一行又写「宿主拒绝了这个操作。」,自相矛盾。
试过「与 title 和 why 都不相等」,仍不行(它跟两段都不相等,但零信息)。
正确判据是有没有具体线索:出现ERR_*/ 大写错误码 / 路径 / 冒号后细节 / 退出码这类
能拿去搜的东西才显示,宿主通用句一律不占地方。新增
verify/host-error-codes.test.mjs(13 条),把 errorCopy 抠出来真调用,覆盖:
12 个码都进表、每个码三段中英齐全、五个高频码真的命中专属文案、未知码仍走兜底、
operation-error 的 why 仍用诊断原文、通用句不再被当线索、真线索仍要显示。 -
feat(ui):详情改成跨整行的独立卡片(用户反馈「展开太丑」)。
错在哪:详情原先渲染成卡片自己的子节点,于是被塞进 232px 的网格窄列——截图一排挤到换行、
风险提示的英文长句变成一大坨、整行卡片被撑到几百像素高,同排其它卡片跟着变高留白。改法:详情不再内嵌,而是渲染成一张
grid-column:1/-1的独立卡片,
由React.Fragment把「卡片 + 它的详情卡」成对放进网格,于是详情自然落在
被点那张卡片的下方一行(不用手算插入位置)。宽度实测 1186px = 网格宽度(spanRatio: 1)。
关闭入口两处:详情卡右上角的 ×(点开就能就地关掉),以及原卡片上那颗已变成
「收起详情」的按钮(原交互不变)。新增
catalog-fields里 4 条断言钉住:PluginCard 里不许再渲染 PluginDetails、
详情卡存在且带data-detail-for+ 关闭按钮、.dshpm-detailCard { grid-column:1/-1 }、
网格用 Fragment 成对渲染。 -
feat:跟上数据源升级——接入 6 个此前完全没接的目录字段(其中能力红线 597 条、截图 1289 条)。
上游数据源仓库
awesome-dsh-plugin/awesome-dsh-plugin的plugins.json新增了市场完全没接的
字段。权威 schema 在它的scripts/build-site.mjs里(拉下来逐条对齐语义,不是猜的)。
实测 2026-10-18 快照(5786 条,经 npm 包dsh-plugin-catalog@2026.1018.7716镜像发布,
两个镜像内容逐字节一致sha256 ea53264a…):字段 条数 之前 现在 capabilityRedLines5275(597 条非空) 完全不显示 详情页「风险提示」 capabilityCheckedAt5275 完全不显示 显示检测时间 screenshots1289(3650 张) 完全不显示 详情页缩略图 tarball581 完全不显示 详情页 + 安装命令兜底 downloadsStart/End2947 完全不显示 下载量的时间口径 downloadsCheckedAt2947 完全不显示 — 最重要的一条是红线。 实测里最该让用户看到的是
reads credentials/secrets AND has network access—— 481 条(读凭据 + 能联网 = 可能外传),
还有runs code at install time (postinstall)22 条、指向云元数据169.254.169.254的 7 条。
市场以前一条都不显示。红线文案原样透传、不翻译:它是检测器的原话,改写会让用户
以为是我们自己验证过。三条不能读错的语义(都照上游注释原文落实):
- 「缺失」≠「干净」。上游:「an entry absent here was NOT scanned — the surfaces print
that as 未检出, never as clean.」实测capabilities与capabilityRedLines是一起出现/
一起缺失的(都 5275 条),缺的 511 条是没扫过。所以内部用三态区分:
null= 未扫描、[]= 扫过没发现、非空 = 扫过有内容。界面上「未扫描」有明确文案
(「未扫描(目录没有这个插件的能力检测记录,不代表安全)」),绝不渲染成空白或绿色。
类型不对(字符串、数字)也判「未扫描」而不是「扫过、没问题」——把坏数据当成安全是在背书。 downloads的null≠0。上游:「null for the ~60% of entries with no npm package at
all — a coverage gap, not a zero. Consumers must tell "not published" apart from
"published, unused".」实测 null 2839 条(无 npm 2819 + 有 npm 未探到 20),
真 0 是 0 条。原来写formatCount(item.downloads || 0)⇒ 把 2839 条「没数据」
显示成「↓ 0」,等于告诉用户「发布了但没人用」。现在显示「↓ 未发布到 npm」。- 截图只许 GitHub 自家 host。上游白名单 4 个 host,理由是「a third-party image host
would let a list PR plant a tracking pixel in every storefront user's browser」。
市场自己再校验一遍(https + 白名单,逐条丢弃不合规的):上游今天合规不代表缓存里的
旧数据合规,而这条约束的价值恰恰是防数据被投毒——只信数据源等于把唯一防线交出去。
tarball是「只能这样装」的兜底:上游说明有些插件只发布构建产物、从源码装不了,
github:owner/repo会给用户一条坏命令。它是会被塞进命令行的字符串,所以形状收紧为
https 且以.tgz/.tar.gz结尾,其余置null。新增
verify/catalog-fields.test.mjs(14 条),钉住上面每一条语义(含三态区分、
host 白名单、nullvs0、坏类型宽容、以及「新字段不能破坏既有搜索/排序」)。顺带发现并修掉一个早已存在的语义错误:下载量把
null折成0(见第 2 条)。
它不是这次升级引入的,是这次对齐上游语义时才暴露出来的。 - 「缺失」≠「干净」。上游:「an entry absent here was NOT scanned — the surfaces print
-
fix:19 个宿主托管的包渲染出「点了必然被拒绝」的开关(用户报「宿主拒绝了这个操作」)。
错在哪:
installedCapabilities里两条判据不同源——toggle: !locked // 只看 readOnlyReason / 全局只读 remove: !locked && !marketSelf && !notRemovable // 还看 removable于是
removable === false且readOnlyReason为空(或查不到文案)的包:卸载按钮不渲染(对),
开关却照常渲染且可点(错)。点下去宿主回400 not-allowed,界面上就是那句
「这个操作被宿主拒绝」——一个永远不可能成功的控件。实测规模(真宿主
/plugin-market/installed,desktop profile,30 个 bundle):
removable=false共 22 条,其中readOnlyReason非空的只有 3 条
(@deepseek-ai/dsh-base/dsh-web-app/dsh-sdk-minimal,都是management-required)。
剩下 19 条(@deepseek-ai/dsh-headless、dsh-sdk-app、dsh-subagent-codex、
dsh-acp-app、dsh-experimental-*…)就是「开关点得动但必然被拒」的那一类。为什么
removable:false就该锁住开关(不是猜,读了宿主实现):
selectBundle(name, enabled)对这类包两个方向都没有可写目标——
禁用方向previous.includes(name)为 false(官方 bundle 由 base 间接引入,名字不在
profile 的dsh.profile.bundles里)会静默跳过;启用方向bundleManifest取不到就抛
ManagementFailure("not-bundle")。宿主已经用removable:false表达了「这个包不归 profile 管」,
市场不该把它渲染成可点的控件再让它失败。与既有约定的关系(这条不能破坏):v1.2.0 定的是「
readOnlyReason查不到文案时
不要凭空锁住」——文案缺失只是 i18n 缺键,不代表宿主没给理由。这条一行没动:
locked的判据保持不变。这次补的是另一条独立事实:removable === false是宿主直接
给出的布尔值,不依赖任何文案。测试里有一条专门钉住「查不到文案时不凭空锁住」仍成立。连带效果(用户选定):这类包现在整卡隐藏(
shouldHideInstalledCard依据
!toggle && !remove),符合此前「做不到的动作就整卡藏掉,别留一个点不动的按钮」的要求。
已安装页会少 19 行,摘要里如实写「另有 N 个已隐藏」——那条断言 e2e 一直在盯着
(「摘要里的数字等于实际渲染的行数」)。全局只读时不隐藏(豁免不变:那时页面唯一
价值就是这份清单)。只修客户端:宿主已经会用
not-allowed正确拒绝,再加一道检查是重复;改动面最小。新增
verify/installed-capabilities.test.mjs(11 条),把installedCapabilities与
shouldHideInstalledCard两个真函数抠出来执行,覆盖:不可移除必须锁开关、普通包不能误伤、
有文案时仍锁、查不到文案时不凭空锁(v1.2.0 约定)、市场自身、全局只读、整卡隐藏四条口径。 -
fix:「更新」把 spec 发错包——2819 条插件点更新必然 400,66 条会装成别的包。
现场取证(
_verify/update-spec-precise.mjs,真实目录 5786 条):客户端「可更新」页点更新时发的是
submitInstall({ name: bundle.name, spec: bundle.name })。而宿主install()看到显式 spec 就走
精确匹配分支:sameKey(item.spec|item.npm|item.url, spec),不认item.name;匹配不上直接
400 not-in-catalog。可决定「哪条算可更新」的matchBundle是认 name 的(npm → shared(name/url)),
两处口径不一致,于是:npm有值的条目 2967 条:bundle 名就是包名,命中npm键,更新正常;npm为空、只有仓库地址的条目 2819 条:spec是 URL、npm是 null,拿包名当 spec
发出去两个键都匹配不上 → 实测 2819/2819 全部 400。也就是说这一整类插件在
「可更新」页上永远更新不动,页面却一直显示「更新到 x.y.z」。
修法:让目录告诉客户端该发什么,而不是猜。
joinBundles多带一个updateSpec——
匹配到的那条目录条目的真实 spec(npm → 仓库地址),且与latest同源(同一个循环里取,
避免「显示更新到 2.0.0、实际装 1.0.0 那条的 spec」)。客户端收成updateSpecFor(bundle)一处:
有updateSpec就用它,拿不到(老宿主 / 没匹配上 / 空串)退回bundle.name——行为与修复前
完全一致,不给老宿主制造新失败面。为什么不修在宿主匹配上(把
item.name加进 spec 分支):那会破...
v1.2.0
-
桌面端为什么没有一键重启按钮:我原来的理由不完整,查清后把真理由写进契约;顺带修掉
「刷新页面就丢掉待重启提示」(用户第六、七轮:先问「你桌面端为什么不做重启按钮」,
再问「是默认生效的吗」)。先纠正我自己。我原先写的理由是「替代进程会被单实例锁挡下」——那只是第一层。
读完壳的lib/main.js(490KB)后,事实更硬,且三条各自独立成立:- 没有「请壳重启自己」的通道。宿主→壳的 IPC 消息是封闭白名单,
壳只认ready/platform-session/shutdown-complete/fatal四种;
其余一律fail("invalid IPC event")并 SIGTERM 掉子进程(main.js:3698-3717)。
「礼貌地请求重启」连消息类型都不存在。 - 宿主一退出,壳当致命故障处理,且不会自动拉回来:
child.once("close")里
code !== 0与code === 0都走fail(...)(main.js:3721-3728)→ 弹原生
「启动失败」对话框。自动重启只发生在壳自己的更新流程里(restoreHost分支)。 - 那个对话框里有一颗破坏性按钮:
[退出应用, 重启应用, 停用第三方插件],
第三颗走disableAllPlugins()——一键停用所有第三方插件(含市场自己)。
让用户撞出「启动失败」框、还可能手滑停掉全部插件,比「请关掉窗口再打开」糟得多。
真正合规的重启通道是壳自己的InstallAndRestart(带任务收割与交接),那是壳的职责。修掉的真问题:待重启提示只活在页面内存里。客户端原来把「待重启」存在 React state,
注释还写着「刷新后没了就没了」——但那是磁盘上的事实,刷新不会让它消失,宿主仍在跑旧代码。
后果正是用户遇到的那一幕:更新完 dsh-mobile 随手刷新 → 横幅消失 → 他以为已经生效,
于是来问「是默认生效的吗」。
修法:宿主半restartPending模块级记账 →/status带pendingRestart→ 客户端挂载时水合。
它成立的关键是语义恰好自洽:模块级变量在宿主真重启时自然清空,不需要任何落盘或过期逻辑。
边界:只记restart-required/requiresRestart(applied已热生效不记)、上限 32 条、
水合不弹窗、不许把restarting退回idle、宿主说没有时什么都不做(不清掉已有的)。验证:真实浏览器 e2e 87→90(新增 [11]:拦截
/status注入「宿主仍记得」→ 整页重载 →
断言横幅出现、且不弹窗、且桌面端仍不给按钮;再反向断言「宿主没有时不得凭空出现」);
门禁 18 套件 PASS(host-contract 38 项)。
变异测试 6/6 全部被抓住,且各自被正确的车道抓住:M5 去掉客户端水合 → e2e 红;
M6 去掉/status的pendingRestart、M7 把applied也算待重启 → host-contract/门禁红
(e2e 抓不到这两条是正确的——它拦截了/status,只测客户端半)。还原后 sha256 逐字节一致。本轮记三条我自己的错(都写进注释,避免下次再犯):
- 模板字符串里写了反引号(连注释里也不行)。它提前结束模板,表达式被切成字符串拼接,
跑起来报restartBtn is not a function——与现场毫不相干,而node --check是过的,
只有真跑才暴露。 - 断言站错地方:刷新后我只等
.dshpm-entry就去查.dshpm-banner,但面板默认是关的、
横幅不在 DOM 里。而且我第一版等的是.dshpm-restartBtn——桌面端restart.available=false
时那个按钮故意不存在。两处都是「测空气」,跟功能是否生效无关。 - 改一个被「抠出来执行」的函数,要连带看谁在抠它:
sendChangeResult新增一句记账调用后,
build-approval.test.mjs(它在裸作用域里new Function(抠出的源码))当场 ReferenceError
假红——缺noteRestartFromResult与它读写的restartPending。门禁在改完当场抓住了它。
- 没有「请壳重启自己」的通道。宿主→壳的 IPC 消息是封闭白名单,
-
开关 ↔ 重启:确认有一处真冲突,并修掉(用户第五轮问「检查开关和重启是不是有冲突」)。
先回答「冲突在哪」——不是「写了同一个文件」,而是粒度不一致:
动作粒度 落盘 开关 文件级 setBundleEnabled→selectBundle写package.json的dsh.profile.bundles;setPluginEnabled→writePluginEnabled写cordis.patch.yml(都用writeFileAtomic,写在半路不留半个文件)重启 进程级 宿主在 RESTART_EXIT_DELAY_MS(900ms) 后process.exit(0)所以真正危险的是「在写操作进行中点重启」:进程会在写入落地前退出;安装还牵着
pnpm 子进程(pnpm 子树可能比宿主活得久)。修复前重启按钮只看restart.phase === "restarting",
完全不看有没有写操作在跑——用户可以在一键更新跑到一半时点「重启 DSH」。同时验明「开关状态能活过重启」(新增
verify/repro-toggle-vs-restart.ps1):
热开关application:"applied"→ 杀掉宿主 → 重启同一 profile →enabled仍是关着的。
持久化不靠内存,所以重启本身不会丢开关状态——冲突只发生在「同时」。修法:写操作进行中(
job非空,或「一键更新」整轮batch.running)禁用重启,
与更新按钮同一个口径;tooltip 说明原因(新增restart.busy)。两处都挡:
横幅按钮的disabled,以及startRestart()自身(弹窗里的「立即重启」是另一个入口,
只挡按钮会留后门)。写操作结束后恢复可点(另加一条反向断言,防止一禁到底)。验证:真实浏览器 e2e 83→86,3 条新断言(前置可点 / 进行中禁用 / 结束后恢复),
先跑到红再改;变异测试 2/2 CAUGHT(去掉 writeInFlight 判定 / 把data-write-busy写死),
还原后 sha256 逐字节一致。本轮记一条我自己的错:第一版把
var writeInFlight = …写进了el(...)的参数列表——
JSX 参数位是表达式,不是语句,node --check当场报Unexpected token 'var'。
第二版把断言放在批量进行中去查.dshpm-restartBtn,那时重启横幅还没出现(它由
restart-required点亮),取到null而失败——那不是修复没生效,是断言站错了地方。
教训:断言「某个东西被禁用」之前,要先确认那个东西此刻真的存在于 DOM 里,
否则测的是空气。 -
上一轮的「版本并集」豁免方案本周就被现实打破(同一天复现,已换成裸包名)。
第五轮我把 profile 的
minimumReleaseAgeExclude写成dsh-context@0.64.0 || 0.65.0,
当次 PASS。但它是会腐坏的:dsh-context的 specifier 是^0.66.0(caret 范围,会自动升版),
升级当天 pnpm 就把触发的- dsh-context@0.66.0追加到列表末尾,而
evaluateVersionPolicy只认第一个同名规则(命中就return)——于是:minimumReleaseAgeExclude: - dsh-context@0.64.0 || 0.65.0 # ← 先命中就返回,0.66.0 不在里面 - dsh-context@0.66.0 # ← pnpm 自己追加的,永远不生效
lockfile 校验再次失败(实测
dsh-context@0.66.0 was published at … within the cutoff)。
我的修复「通过了一次」,但没通过时间。改成裸包名
- dsh-context(放行该包所有版本)。理由不是它更"安全",
而是它对「先命中者胜」是稳定且自愈的:pnpm 之后再怎么往末尾追加,都不会再破。
代价写在注释里:这个包不再享受 24h 发布冷静期(本 profile 的有意取舍)。验过两件事(不只是「改完绿了」):
① 真 profile 上pnpm install --lockfile-onlyPASS;
② 模拟 pnpm 再次追加同类条目后再跑,仍然 PASS(这正是上一版缺的性质)。同时把客户端
err.supply-chain.next的建议改对了:原来教「写一条版本并集」,
现改为明确说明并集只能生效一次、并教裸包名,并加断言禁止再出现并集写法
(assert.equal(/dsh-context@0\.64\.0 \|\| 0\.65\.0/.test(...), false))。教训:修一个「方向性」bug 时,要问的不只是「现在好了吗」,还有
「谁会在什么时候把它再弄坏」。这次的破坏者是 pnpm 自己的追加行为——
它就在同一个文件里、每次升级都会发生。 -
热开关:证伪 + 一个真缺陷(第六轮;用户要求「完成文档里面的热开关」)。
结论:热开关不用做——宿主本来就会热应用。 ROADMAP §3.3 把它列成待做的大件,
理由是「现在完全依赖宿主setBundleEnabled;热开关能免重启」,这个前提是错的。实测(新增
verify/repro-hot-toggle.ps1→verify/probe-toggle-hot.mjs,跑在 scratch
profile 上,不碰用户的 desktop profile):POST /plugin-market/toggle {"name":"@feiyang666/dsh-usage-plugin","enabled":false} → 200 {"ok":true,"changed":true,"application":"applied","enabled":false,"error":null,"warnings":[]}application:"applied"= 已经热生效,没有重启。机制取自宿主源码:setPluginEnabled
自己就writePluginEnabled(this.profile.patchPath, …)写 patch 文件,紧接着reload()→
reconcileProfilePatches(...);而dsh-base的 bundle 常驻加载
- id: hmr / name: '@deepseek-ai/dsh-hmr',本 profile 的 HMR 在场。佐证:用户的
cordis.patch.yml里本来就有宿主写过的开关行(- id: whale-mode / disabled: false)。所以自己写
cordis.patch.yml既多余又有害:违反写下来的安全不变量
(PLUGIN-MARKET.md §5「host 半只做 GET 目录与调用宿主服务,不写任何文件」),
还得抄参考实现约 45 条受保护模块正则(写错一条就可能把 boot 链自己关掉)。
调用者不该绕过被调用者去改它的文件。顺带查出一个真缺陷(已修,TDD):
toggleNotice只认cancelled/changed,
对宿主另外两种可达结果都回绿色「已启用 {name}」:宿主回 修复前 真相 applied「已启用 X」✅ 对 restart-required「已启用 X」绿色 ❌ 还没生效(无 HMR 的 profile) overridden「已启用 X」绿色 ❌ 被覆盖层压住,状态没变 overridden由宿主setPluginEnabled显式 return(index.js:1658),不是假想分支。
修法:补两条分支(warn +「重启 DSH 后生效」/「被覆盖层压住,实际没有启用」),并把
!changed与noticeFromResult统一到同一条排除规则。applied语义有意与安装路径不同:
安装问「包装上了吗」(true),开关问「状态真的切过去了吗」(false)。验证:门禁 PASS(18 套件;client-errorcopy 22→25,3 条新断言先跑到红再修);
真实浏览器 e2e 83/83。这一轮的教训:「文档说缺什么」不等于「真的缺」。这条 if 从写下到被当任务执行,
中间隔了好几轮;真正推翻它的是一次在 scratch profile 上真发出去的请求,
而不是再读一遍参考实现。同时它也暴露了同一个病的第二次发作——
用一条 happy path 的规则去覆盖一个有多分支的真实接口。 -
修「有更新时按钮却还写『检查更新』」(用户第四轮报:
「有更新时检查更新状态机应该为一键更新」)。可更新页左边那颗按钮的主动作由
checked && bundles.length > 0决定,也就是先逼用户点一次
「检查更新」,它才肯变成「一键更新(N)」。但那一页打开时,列表已经把 N 个可更新插件
逐条摆出来了、页签角标也写着 N——信息早就到手了,按钮却让用户去「检查」一件刚刚
已经确认的事。真实复现(把 e2e 断言改成首屏即「一键更新(2)」后跑红):✗ 页头右侧首屏就是「一键更新(2)」与「插件市场更新(四态之一)」两个按钮 实际:["检查更新","插件市场更新"]改法:主动作改由数据决定 ——
var canUpdateAll = bundles.length > 0;。
checked保留,但只用于没有更新时区分「检查更新」还是「重新检查」(那个区分是有意义的:
没检查过就不该谎称「已检查」,这条不放松)。先写会失败的断言 → 跑到红 → 再改代码 → 跑到绿(TDD),并把那条「先点检查更新再点一键更新」
的旧断言改成「首屏无需任何点击就已是『一键更新(2)』」+「不再存在多余的『检查更新』按钮」。验证:门禁 PASS(18 套件);真实浏览器 e2e 83/83(净 +1)。
变异测试 2/2 CAUGHT(改回checked && …/ 改成恒false),还原后 sha256 逐字节一致;
另把 e2e 的这条断言也做了变异(把代码改回旧写法 → e2e 转红),确认它真的承重。 -
修 2 处缺陷(1 条 HIGH)+ 一个真实环境故障的根因:用户报「dsh-mobile 更新失败」
(第五轮:从.plugin-manager/logs的真实失败日志倒推,逐条用 pnpm 复现,
修前修后都跑了变异测试)。-
「dsh-mobile 更新失败」的真根因不在插件里,在 profile 的
pnpm-workspace.yaml——
同时暴露了插件自身 1 处 HIGH 文案缺陷(见第 1 条)。根因:pnpm 11.7.0 把
minimum-release-age的默认值设成 1440 分钟(24 小时)
(dist/pnpm.mjs:145910"minimum-release-age": 24 * 60,注释写着// 1 day)。
lockfile 里只要有一个包是 24 小时内发布的,整个 lockfile 就校验失败并抛
ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION——与 registry 通不通无关。
用户的 profile 里dsh-context@0.65.0(发布于故障前 19 小时)正卡在这个窗口内。为什么「已经加过豁免」却还是失败:profile 里写了
- dsh-context@0.64.0和- dsh-context@0.65.0两条同名规则,而 pnpm 的
evaluateVersionPolicy(pnpm.mjs:64074)是「匹配到第一个同名规则就return」:for (const { nameMatcher, exactVersions } of rules) { if (!nameMatcher(pkgName)) continue; if (exactVersions.length === 0) return true; // 裸包名 = 全放行 return exactVersions; // ← 只返回第一条,后面的被忽略 }
第一条只含 0.64.0 ⇒ 0.65.0 拿不到豁免 ⇒ 每次都失败。
实测规律(各跑 2–3 次确认):0.64.0 + 0.65.0(失败)/0.65.0 + 0.64.0(通过)
——同样的两条内容,只改顺序结果就不同,正是「只认第一条」的直接证据。修法:把两条并成一条版本并集
dsh-context@0.64.0 || 0.65.0
(parseExactVersionsUnion支持||)。刻意不用裸包名dsh-context——
那等于永久关掉这个包的发布冷静期保护,为修一次更新而拆掉一道供应链闸门不划算。
已备份原文件为pnpm-workspace.yaml.bak-minage-*,改后实测真实pnpm add dsh-mobile@0.6.1
exit=0,dsh-mobile 0.5.5 → 0.6.1 落盘。顺带确认的一件事:我第一次复现时看到「移除豁免反而通过」,差点据此写结论——
核实后那是 pnpm 把上一次的校验结果缓存了(输出里的verified 8m ago)。
清掉 cache/store 重跑后 A/B 稳定复现。缓存会让错误结论看起来可复现。 -
「供应链策略拦下」被误报成「连不上 npm 源」,劝用户去配镜像——HIGH。
真实的失败日志同时含[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION]和
GET https://registry.npmmirror.com/… error (UND_ERR_DESTROYED)。后一行是结果不是原因
(校验一失败 pnpm 就放弃下载),但网络正则会命中它 ⇒ 修复前errorCopy给出
title=连不上源 / next=配镜像。配镜像、换源都不可能修好——这是「自信地指错方向」,
比不说话更糟:用户会照做、浪费时间,然后更困惑。
做法:新增supplyChainDetail()(认ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION/
failed supply-chain polic/minimumReleaseAge cutoff),优先级排为
供应链 > 占用 > 网络(三处:errorCopy的 prefix、registryUnreachableDetail
的自让开、shortFailureText),并新增 zh/en 的err.supply-chain.{title,why,next,row},
文案里明确说了「这不是网络问题」+ 同一个包只能写一条规则这个坑。
用真实日志原文跑修复后的errorCopy验证:err.supply-chain.*。
验证:门禁 PASS(17 套件;client-errorcopy 14→22、client-copy 35→36、
error-classify 18→18);真实浏览器 e2e 82/82。
新增断言全部过了变异测试(4/4 CAUGHT,还原后 sha256 逐字节一致)。
其中一条 M2 一开始是 MISSED——删掉 `registr... -
v1.1.6
-
核心逻辑审计修复:身份匹配、内容校验、错误归类三类真实缺陷(用户要求「检查核心代码
是否有逻辑错误」并按「现代应用市场的逻辑」修复):三份独立审计(catalog / self-update+http /
host 路由)共报 26 条,我逐条在真实 4412 条目录快照上复核——复核否掉了 3 条(排序并列组
数量统计错误、搜索「跨字段命中换行」未复现、page>pages属契约允许),其余按严重度修掉:- 目录身份层(会装错包,最高优先):
matchBundle曾在 npm 名未命中时把 scope 剥掉再查,
而目录里 1431/4412 条是「无 scope 且npm === name」——装任意一个目录里没有的
@随便/<name>就会被认成那个条目(假「已安装/可更新」),点更新时pinnedNpmSpec还会把
spec 钉成别人的包名,真的装成另一个包。现在按可信度分层匹配(npm 名 → 仓库名 →
@owner/仓库名),取消去 scope 兜底。
repoTail也修了:它声称「剥掉 /tree/… 的尾巴」实际只取 URL 最后一段,于是
…/owner/repo/tree/main/adapters/dsh的身份键变成通用词dsh(实测 471 条含/tree/、
435 条尾段不等于仓库名、181 条 bogus 键真被用上,dsh这一个键被两个仓库争抢)。
buildMatchIndex同时收repo/owner/repo/@owner/repo,一个键落到两个不同条目时
标记歧义、不再匹配(原来是「先到先得」,哪条被标成已安装纯看文件顺序)。 - 内容闸门(市场可能被静默清空):
validateCatalogPayload只查类型,于是
{count:4412, plugins:[]}(源站故障/截断)会被判为合法并以stale:false覆盖好缓存——
市场整个变空、分类 chips 消失,而且因为不是 stale 连「缓存可能过期」横幅都不显示,
看起来像「真的一共 0 个插件」。现在交叉核对plugins.length与count(1% 容差)。 - 主用 npm 源静默失效:npm 路径
JSON.parse(file.toString('utf8'))不剥 BOM,而 URL 路径走
response.text()会被 fetch 规范自动剥掉——包内plugins.json带 BOM(本仓库自己就有
ps-bom.test.mjs)时每个请求都失败,静默退化到下一个 registry 再到 30s 的官方 URL。
现在trimStart()。同时dist.integrity由「有则校验」改为必填:元数据本身也来自网络,
只信「元数据说没问题」等于没校验,缺字段即整源失败(与文件头声明的信任模型一致)。 - 搜索:
url曾在搜索 haystack 里,于是搜github命中全部 4412 条(几乎每条都有
github 地址),用户以为搜到了什么其实全量;字段间用\n连接还导致跨字段子串命中。
现在只搜 UI 承诺的字段(名字/作者/描述/能力),多词是「与」语义并按字段匹配;另加
NFKD 去变音符号(jose能搜到José,实测目录里 25 条描述带重音)。 - 分页与排序:
paginate在page超出末页时收敛到末页(原返回page=5/pages=2/items=[],
界面显示「第 5/2 页」+ 空网格且不会自愈——客户端只在改搜索条件时重置页码);sortPlugins
追加id兜底键保证全序(真实数据有 171 组完全并列,原来是「谁先来谁在前」,
刷新源顺序就会换页位)。 - host 契约:
sendError曾静默丢掉overrides.diagnostic——契约 §2.8 的字段永远不会出现,
而客户端靠它对 EPERM/EACCES/EBUSY 给出「文件被占用」的可操作提示;sendChangeResult的
ok只看error,于是宿主application:'failed'(error是可选的)被报成ok:true,
客户端渲染绿色「已安装」并计为成功(cancelled保持ok:true,否则客户端会抛错丢掉「已取消」文案);
重名插件安装由「第一个匹配就装」改为报歧义(真实目录 195 个重名,dsh-memory对应
10 条 5 个不同 spec——用户在装别人的包);MANAGEMENT_MESSAGE/HINT改Map
(对象字面量 +__proto__这类键会拿到Object.prototype的值,而契约要求 message 是字符串)。 - 重启链路:
restart端点改为先安排退出、再写响应(原顺序在响应写失败时——客户端切走/
代理断开——异常被外层吞掉,退出永不安排,而restart.js已置requested,之后每次点击都回
already:true并跳过安排 → 「重启 DSH」永久失效);restart.js把child.on('error')
提到pid<=0那条提早 return 之前(spawn 的 ENOENT 是异步事件,未处理的'error'
会直接终止宿主进程,用户看到 500 之后进程就没了),并在该路径顺手unref。 - 自更新通道(两条「谎称已是最新」):
isNewer曾把compareVersions的null折叠成
false,于是本机是合法 npm 预发布号(1.1.5-rc.1)时apply()回
{ok:true, application:'up-to-date'}、界面显示「已是最新」——正是文件注释里禁止的「猜」,
现在isNewer返回true|false|null、check()对不可解析的本机版本如实报通道不可用;
另一个源知道有更高版本却拿不到清单(附件缺 digest → 回落到 v1.1.6 起已下线的
releases/index.json)时,原先那份更旧的候选会把结果盖成「已是最新」,现在这类
「知道有新版但验不了」让整次检查失败并说清是哪个版本。另:标签名过isUsableTag
校验(孤立代理项能通过\S+却让encodeURIComponent抛URIError,从单源坏数据
变成 500 internal);mkdir/writeFile/rename的文件系统错误归类为契约里的
self-update-download(原先是裸 await → 500 internal,客户端那条专用文案永远用不上)
并在失败时删掉.part。 - 客户端:目录过期横幅的原因永远是「原因未知」(服务端
/status给的是字符串错误码、
/catalog压根不带 error,客户端却按对象读error.message/.code)——现在字符串/对象两种
形态都认,认不出的码原样显示(「原因未知」让人没法判断是网络、限流还是源站挂了);
「正文不是 JSON」原先归到internal(反代返回 HTML 时界面说「宿主内部出错 / 看宿主日志」),
err.badResponse.*三段文案成了死文案,现在 2xx 却给出非 JSON 归badResponse;
err.badResponse.why里的{status}占位符从未被插值(t没收到第二参),也一起修;
写操作的忙碌态改为按作业 key 归位(原clearJob()无条件清空:装 A 时点装 B,A 完成
会把 B 的忙碌态也清掉,界面显示空闲 → 用户再点一次就是重复安装),并加了同 key 去重
(读 ref 而非 state,否则同一批事件里连点两次守卫失效)。
回归:新增两个套件——verify/catalog-identity.test.mjs(36 条:身份层/内容校验/搜索/排序/分页,
有真实快照时一并跑规模复核)与verify/host-contract.test.mjs(16 条:diagnostic 透传、
ok推导、重名歧义、重启顺序、error 监听顺序、Map 映射表),两者都由门禁自动执行;
client-copy.test.mjs26→30/30、self-update.test.mjs39→43/43。
这一批审计同时留下一条方法论记录:三份审计共 26 条结论里 3 条经复核不成立,不能照抄。
- 目录身份层(会装错包,最高优先):
-
上一版修复的对抗性复核:抓出并修掉 4 条我自己引入/残留的缺陷(把 diff 交给独立一方
专门找错,而不是自己再读一遍)。这一轮全部由可复现的证据驱动:- 「一键更新」会永久卡死(最严重,是我上一版引入的):新加的「同一个包正在装就吞掉重复
点击」守卫只return不回调,而一键更新的顺序执行靠回调推进——用户在「已安装」页插进
一个正在被批量处理的包时,那一步既不发请求也不回调 → 循环断掉、batch永远{running:true}、
按钮被自己的守卫挡住,整个会话内一键更新彻底失效、只能刷新页面。
根因还有一处不一致:「已安装」页的更新按钮原先不像「可更新」页那样受batchRunning约束,
正好从那一页漏过去。修法三条一起上:守卫命中时也回调(并在批量里把这类记为skipped,
既不算成功也不算失败)、已安装页按钮补上batchRunning、新增「{name} 正在装/更新,已跳过。」文案。 - 「取消安装」的文案是死路径:
ok判成error === null && application !== 'failed',
于是宿主「取消 + 带一个说明原因的 error」时ok=false,客户端requestJSON直接抛错,
notice.*Cancelled永远渲染不出来。现在cancelled恒为ok=true(无论有没有 error)。 - 过期原因仍有一半路径是「原因未知」:上一版只让客户端认字符串错误码,但
/catalog
响应里根本没有error字段,而客户端优先读的正是它——只有/status那条半路径能拿到。
现在/catalog也带error: cache.error ?? null。 - 搜索与身份的过度修复(两条都是我上一版用力过猛):
- 为了让「搜
github不再返回全量」而整段删掉url,导致用户从 GitHub 复制地址粘进
搜索框变成 0 结果(https://github.com/bycall/dsh-answer-reviewer由 1→0)。而且
复核发现「搜 github 出全量」的真凶是描述里写了 github、不是 url。现在 url 回来了,
但只对「地址形状」的词生效(含/或.):粘贴完整地址精确命中,搜通用词仍不会全量。 - 为了杀掉「
@随便/同名误命中」而删掉 scope 兜底,把 1431 条合法匹配一起杀了:
dsh plugin add <git url>装出来的包名常是@owner/name,而目录里那条的npm是无 scope 的
name——真实目录里 1431 条正是这种形态(复核用joinInstalled().installed量化:0/1431 全落空)。
正确修法是按 owner 对别名(@owner/name只在目录里确有 owner 对该仓库时成立,
@unknownorg/name仍不匹配)。同一轮里还修了两处哨兵误伤:
① 同一仓库的多个子插件(name形如repo#sub,真实 50 个仓库、463 条带#)被误判成
「重名歧义」而全部不显示已安装——现在owner/repo、@owner/repo是共享键(命中多条都算),
只有不同 owner 争用裸仓库名时才判歧义;
② 条目name里的#子目录后缀没剥掉,导致@owner/repo对不上。
- 为了让「搜
- 内容校验收紧了但要给手工源留活路:1% 容差对
count=10/实际 13这类手工
DSHM_REGISTRY_URL目录过严(整源被拒),改成小目录(≤100)给 5 条余量、大目录按 2%。 unverified去重:多个源报同一个版本时诊断文案里出现「1.1.6、1.1.6」。- 注释的论证站不住:重启端点「先排退出再写响应」这个顺序是对的(防御性),但我原先写的
因果链(「响应写失败 → 异常被吞 → 退出永不安排」)经实测缺少证据——对已 destroy 的 socket
写并不抛错,只有对已 end 过的 res 才会同步抛ERR_STREAM_WRITE_AFTER_END。注释已改成诚实版本。
本轮同时把两条只 grep 源码形状的断言换成了真调 handler 读响应体的行为测试
(sendChangeResult六种 application/error 组合),因为形状断言换个等价写法就会假红/假绿。
host-contract.test.mjs16/16、catalog-identity.test.mjs36/36、client-copy.test.mjs
30/30、self-update.test.mjs43/43、门禁与 e2e 64/64 全绿。
- 「一键更新」会永久卡死(最严重,是我上一版引入的):新加的「同一个包正在装就吞掉重复
-
补测:把「全绿」里仍存在的空洞补上。复核发现上面那批修复的回归覆盖并不完整,补了三处:
- 「一键更新永久卡死」的入口在真实浏览器里补了一条回归(e2e 64→65/65):批量进行中切到
「已安装」页,断言那颗更新按钮确实被禁用。这是当时唯一漏掉的入口——「可更新」页的行内按钮
有batchRunning、这一页没有,复核脚本正是据此认定「真实用户可达」。现在由真实浏览器兜住,
而不只是源码里 grep 到那个字符串。 findCatalogItem改成真调(host-contract 16→18/18):身份层(id/npm/url)命中
即确定;显示名层唯一才确定、重名报歧义且item === null(绝不返回其中任意一个)。
这是「会装错包」那一类里唯一还只剩源码形状断言的地方。- 线上通道体检(临时探针,跑完即删、不入库):GitHub Releases API 确认每个版本的附件都带
digest(sha256)、@v1.1.5/releases/index.json仍 200 而@main/…是 404、真实
check()以current=1.1.5跑通(ok:true、channel:github-release)。
它证明「dist.integrity与 digest 的加固没有把真实通道打死」——加固类改动必须对着真实环境
验一次,否则「更严格」很容易变成「永久不可用」。
这一节的方法论:
verify/里的断言分两种价值——真调行为(能抓住回归)与源码形状
(只证明那个字符串还在文件里,换个等价写法就会假红/假绿)。这批修复里凡是「会装错包 / 会卡死 /
会谎报」的高危路径,都已经至少有一条真调或真实浏览器的断言。 - 「一键更新永久卡死」的入口在真实浏览器里补了一条回归(e2e 64→65/65):批量进行中切到
-
三个页签页面统一间距与高度,新增页面只改一处注册表(用户报:发现 / 已安装 / 可更新
三个页面「高度不对齐」,并要求「设计成统一的,方便后续添加页面」):根因是三页各写各的
外壳——发现页是「工具条 + 分类 + 汇总 + 网格」,已安装页是「汇总 + 行列表」,
可更新页却是一张带边框和内边距的卡片(.dshpm-updatesPanel的padding:12px 14px),
那 12px 内边距把这一页的第一行文字整体推下去,加上容器节距有 8 也有 10,切页签就能看到
内容上下跳。修法三层:- 统一外壳:新增
.dshpm-page,渲染处把当前页套进去,三页共用一套节距(12px)
与起点;.dshpm-updatesPage作为整页时去掉卡片内边距与边框(基类留着,便于别处复用)。
页面级间距统一成一个单位 12px(gap),卡片/列表行内部仍是 8px(块内间距)。
.dshpm-page用flex:1 0 auto覆盖.dshpm-root > *的0 0 auto:内容不足一屏时
页面撑满剩余高度(几页等高),内容超长时仍由面板根自己滚动、任何区块都不收缩
(§12.12 的不变量不变,没有引入第二个滚动容器)。 - 固定页签高度:
.dshpm-tab改inline-flex+min-height:32px,带角标(可更新)
与不带角标的页签一样高——后续新增页面加角标、加图标都不会把页签栏撑高。 - 页签/页面注册表:新增
MARKET_TABS(id / 文案 / 角标 / 入口)与MARKET_PANES
(各页渲染函数),页签栏循环生成、页面查表渲染。新增一个页面 = 加一行页签 + 加一个
渲染函数,正文不再有嵌套三元分支;第三个页签的角标计数与goUpdates入口也声明在
注册表里(data-tab便于断言与定位)。
回归:client-copy.test.mjs改钉注册表(顺序、UpdatesPane 由MARKET_PANES渲染、
tab === "installed"分支写法已消失)并新增 1 条布局不变量(外壳节距 / 不收缩 / 页签
固定高度 / 三处 gap 都是 12px),24→25/25;market-ui.e2e.mjs新增[2c]用真实几何
取证——三个页签按钮等高、页签底边到页面顶边节距都是 12px、三个页面的第一行内容顶边
完全一致(实测 120/120/120)、内容不足一屏时已安装与可更新两页等高,并留一张
market-tab-alignment.png对照图,58→64/64。注意:页面入场动画(dshpm-rise,从
translateY(7px)起)与页签底线 0.26s 过渡都会污染几何测量,断言前必须等动画落位
(首轮实测正是量到中间帧,把对齐误判成差 7px)。
- 统一外壳:新增
-
打包与发布迁入 GitHub Actions,本地不再产生任何打包产物(用户任务:每次本地打包发布
太消耗 token):release.ps1本地只做「门禁 → 递增 → 提交 → 打标签 → 推送」——
-LocalOnly现在只跑门禁、不打包;新增 CI 专用-CiPack(仅GITHUB_ACTIONS=true放行,
本地调用直接被拒)。新增.github/workflows/pack-release.yml:标签推送触发(也可
workflow_dispatch补跑),门禁 →pnpm pack→gh release create(Release 已存在则
upload --clobber幂等补附件)→publish-npmjob(版本已在 npm 上则跳过;凭据走
npm Trusted Publishing(OIDC,id-token: write),不再依赖长期 token——对齐 npm 官方
2FA-bypass token 弃用时间表:2027 年初直接发布能力将被移除)。npm 发布必须并进这个
workflow:由 GITHUB_TOKEN 创建的 Release 不会触发其它 workflow,publish-npm.yml
只剩人工建 Release 与手动补发作用(同样的 OIDC 权限,原NPM_TOKEN方案与
npm stage publish人工审批方案写在各自注释里)。 -
releases/目录从仓库移除,回退一律从 GitHub Release 附件下载:6 个 tarball 与
index.json全部git rm,本地dist/清空——仓库里从此不留任何打包产物,
旧版安装与回退都指向 Releases 页附件(releases/download/<tag>/<name>-<version>.tgz>)。 -
自更新检查改为 GitHub 附件元数据直读(
releases/下线的连带修复,不修则下个版本
按钮必坏):github-release源用附件digest(sha256)与size当场组装条目、不再查
仓库里的index.json;附件缺digest时退回老路(≤v1.1.5 的标签仍带清单)。新版本的
jsDel...
v1.1.5
- 首次进入「可更新」页不再显示「再次检查」(用户报:第一次进入这个界面怎么会是再次检查的
状态机,应该是检查商店更新):根因是启动时那次自动检查把模块级状态机推到了ready,
页面一打开按钮就成了「给点过的人看」的再次检查态。现在runSelfCheck(onResult, manual)
区分来源——自动检查没更新时停在初始态,首次进入仍是「插件市场更新」;「再次检查」只属于
用户手动点过的那次检查。自动检查发现新版本时照常直接亮「更新到 x.y.z」(那是给用户的
信息,不是替他按下检查);手动点 → 「正在更新…」→ 没更新 →「再次检查」的四态顺序不变。
回归:verify/client-copy.test.mjs(manual 分支断言)+verify/market-ui.e2e.mjs
(首次进入断言data-state === "idle"且文案是「插件市场更新」)。 - 根 README 参照 dsh-mnemon 的版式重做(用户报:README.md 做的太丑了):居中标题、
语言切换行、徽章行、标语 + 头图、速览链接,正文按「为什么做它 / 界面速览 / 快速开始 /
它怎么工作 / 数据来源 / 安全边界 / 开发与验收」组织;顺手订正旧版的过期说法
(v1.0.0 安装示例、「47 条断言」、「装完由 HMR 自动生效无需重启」)。配套在 e2e 里加了
发现页头图截图(拍前拍后都断言停在发现页签),docs/assets新增
market-discover.png与market-updates.png。
- versionName
1.1.5/ versionCode10105/ build+54.43a8583 - 安装:
dsh plugin --profile web add <本页附件 deepseek-harness-market-1.1.5.tgz>(发布到 npm 后可直接add deepseek-harness-market) - 目录源以 npm 镜像优先(实测 289ms)对官方源(本机直连 25–93s 超时)
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md
v1.1.4
包名变更:dsh-market → deepseek-harness-market(前序:dsh-plugin-market → dsh-market)。
两次都是被 npm 规则挡回来的,如实记录:
dsh-plugin-market已被另一个项目占用(fireguo / veloce-ailab,latest 1.3.0);- 改用的
dsh-market被 同名规则拦下——与已存在的dshmarket(v1.66.8)只差一个连字符,
registry 返回403 Package name too similar to existing package dshmarket,--access public
也绕不过。registry 建议加 scope,但用户名winnie_0721含下划线、scope 不允许下划线; - 最终选定
deepseek-harness-market,实测可用且近似名(deepseekharnessmarket、
deepseek-market等)均无冲突。
影响范围:
package.jsonname、cordis.patch.ymlpatch name、lib/index.jsPLUGIN_NAME、
lib/client.jsbundle id / SELF_NAME / STYLE_ID、lib/self-update.jsMARKET_PACKAGE- 所有文档、测试、release 脚本中的包名引用
- GitHub 仓库名不变(仍为
Winnie-0721/dsh-plugin-market),self-update 的 MARKET_REPO 不变 - tarball 文件名变为
deepseek-harness-market-<v>.tgz
迁移注意:已安装旧名的 profile 需手动换装:
dsh plugin --profile <profile> remove dsh-plugin-market # 或 dsh-market
dsh plugin --profile <profile> add <新包路径或 npm 名>仓库位置迁移:仓库整体从 E:\AI\DeepSeek Harness\Dsh 迁到 E:\Code\dsh-plugin-market
(按 E:\Code 下「目录名 = GitHub 仓库名」的放置惯例),GitHub 仓库名与 npm 包名均不变。
- README 的安装说明改为 npm 优先(
dsh plugin --profile web add deepseek-harness-market),
本地目录示例改成新路径;顺带删掉「本包没有发到 npm」这句已过期的话 verify/lib/catalog-fixture.mjs的默认快照路径改成按本文件相对位置解析,不再写死绝对路径- 文档中的绝对路径改到新位置;
verify/REPORT.md与docs/PLUGIN-MARKET.md§8.1 的历史验收记录
不改写,只加一条路径后记(证据必须留在它当时的上下文里)
「更新插件」按钮补上反馈(用户报:点它没有任何反应,而旁边的「检查市场更新」有)。
两个根因,都修了:
- 面板开在视区外——可更新面板在 DOM 里排在目录卡片网格之后,点开时它在几百像素之下,
看上去就是「什么都没发生」。现在展开后scrollIntoView({ block: "nearest" })滚进可视区,
用户偏好减少动效时直接跳位、不放平滑滚动。 - 回执只在「有更新且首次」时才发——现在与「检查市场更新」一个待遇:按钮进入
「检查更新中…」忙碌态(spinner +aria-busy+ 禁用),重读完成必定给一句话:
有更新走notice.updatesFound,全部最新走新增的notice.updatesNone,失败走错误提示条。
页面挂载与后台重读不回执,避免每次 tick 都弹提示。
回归断言加在 verify/client-copy.test.mjs(忙碌态 / announce 回执 / scrollIntoView 三条)。
回执统一成悬浮气泡(Android toast 那种;用户要求「这几个更新弹出改成统一的气泡弹窗」):
.dshpm-notice从「页内提示条」改成position:fixed:贴视口底部居中(left:0; right:0; margin:0 auto)、圆角 14px + 投影、max-width:min(560px, calc(100vw - 32px))超宽换行。
不占文档流——出现/消失都不推动布局,也不用滚到那一页才看得见(旧版挤在头部下面一格)。- 入场动画换成
dshpm-toastin(自下而上 + 轻微缩放,backwards填充);prefers-reduced-motion
下照旧全部关闭而内容完整可见。 - 一字未改:kind 配色与图标、4.6s 自动收起与倒计时线、警告/错误保留到手动关闭、
role/aria-live。 - 横幅仍是页内条(目录过期、只读、构建待批准):那是要用户处理的持久状态,不是回执。
- 断言:
client-copy.test.mjs加样式不变量(fixed / 贴底 / 居中 / 后续规则不得用
position:relative把它盖回文档流);market-ui.e2e.mjs加 3 条几何断言——真引擎里量
getBoundingClientRect,因为祖先一旦带transform/filter就会抢走 fixed 的包含块,那时
position的计算值仍然是fixed,只有几何量得出来 → 35/35
「可更新的插件」改成第三个页签(用户圈的位置:已安装右边再开一个分页):
- 页签栏变成
发现 / 已安装 / 可更新,第三个带可更新计数角标(与侧边栏入口、可更新页同一份计数)。 - 原来那个「点按钮就地展开的抽屉」退场——内容搬进页签整页显示:
UpdatesDrawer→UpdatesPane,
头部「更新插件」按钮改为setTab("updates")+ 重读回执(该按钮随后在下一条里整个删掉,页签成为唯一入口)。 - 顺带删掉只属于抽屉的东西:
data-open折叠过渡、max-height/overflow:hidden(更新条目多时
不再有被裁的风险)、scrollIntoView滚动定位、关闭按钮及其样式、data-drawer根属性。 - 气泡回执保持原样(上一条的悬浮 toast 没动,那是用户先要的)。
- 断言:
client-copy.test.mjs加两条(页签顺序 / 页面组件 / 抽屉必须退场),market-ui.e2e.mjs
把原来点抽屉的步骤全部改成页签步骤,并加 3 条(三个页签、默认停在发现页、切走后内容卸载)→ 38/38
头部按钮大扫除 + 自动检查 + 一键更新(用户这一轮提的一整条需求):
- 右上角只留「刷新目录」:删掉「更新插件」(第三个页签与角标取代了它)与「检查市场更新」
(搬进可更新页页头)。两个按钮的反馈原样搬过去——忙碌态 spinner +aria-busy+ 回执气泡;
相关文案action.updates/action.checkingUpdates随之删除(不留僵尸键)。 - 自动检查规则(
startUpdateScheduler,在apply里启动、schedulerStarted守卫):- 每次启动 DSH → 检查一次市场本体更新(
GET /self-update,宿主 10 分钟缓存),结果写模块级
状态机:可更新页的按钮一打开就是检查结果; - 启动后每 1 小时 → 检查一次插件更新(
GET /installed,绕过 5 分钟计数 TTL),刷新角标;
只有「多出新更新」且市场页开着时才补一条回执,否则不打扰; - 定时器
unref()、回调包try/catch:否则style-heal.test.mjs这类会调apply的回归测试
会被每小时的定时器拖住永远不退出(实测卡死后定位到)。
- 每次启动 DSH → 检查一次市场本体更新(
- 可更新页页头的两个按钮:
- 「检查市场更新」(四态状态机,从头部搬来,启动那次自动检查已经写过它);
- 「一键更新(N)」(新):按顺序逐个执行同一条安装接口,逐条结果留在该行,跑完给一条汇总回执;
撞上「要先批准构建脚本」就暂停并把决定权交回用户。逐条的「更新到 x.y.z」保留。 - 顺手修掉一处自相矛盾:无更新时回执改成说本机版本——此前用远端
latest,会出现
「标题写着 1.1.4、提示却说已是最新 v1.1.3」(用户截图里就是这个)。
- 回执气泡再进一步:入场换成带回弹的曲线、毛玻璃(
backdrop-filter)+ 分层投影;新增退场——
先翻data-open="false"下沉 200ms(NOTICE_CLOSE_MS)再卸载,不再凭空消失;reduced-motion下
照旧立即消失、内容不受影响。 - 文案:
notice.updatesFound不再让人去点已删除的按钮;updates.hint说明两种跑法。 - 验收:
client-copy.test.mjs17/17(新增:头部只剩刷新目录、两个新按钮的反馈、自动检查
规则、退场动画);market-ui.e2e.mjs40/40(批量那两次/install由 CDP 拦成确定性成功,
不去打宿主的目录校验)。顺带修掉 e2e 失败时一行输出都没有的坑:process.exit(1)会把管道里
还没刷出去的console.log全吞掉——改用process.exitCode = 1。
第二轮截图反馈(toast 精简 / 删黑条 / 修更新失败的回执):
- 标题上方的黑条进度条删掉(用户点名):
.dshpm-progress节点与样式全部移除,写操作的
「进行中」改由触发它的按钮表达(spinner +aria-busy+ 禁用),进度感不再横在标题上。
e2e 新增断言:批量进行中(按钮aria-busy=true的窗口)页面里.dshpm-progress必须为 0。 - 回执文案精简(用户点名「toast 尽量精简」):
目录已刷新,共 4412 个插件→已刷新 4412 个插件发现 2 个插件有新版本,可逐个「更新到 x.y.z」或点「一键更新」。→2 个插件有新版本。已检查 15 个插件:全部都是最新版本。→全部都是最新版本。一键更新完成:成功 2 个、失败 0 个。→更新完成:成功 2、失败 1。- 英文侧同步精简;e2e 相应正则改写。
- 更新失败的回执翻译成人话(用户报「点更新会失败」,先查了 dsh-market 的升级逻辑再修):
- 根因:宿主的
EPERM: operation not permitted, scandir …(运行中的 DSH 占着dsh-our-free-model
的文件 / 该目录权限已坏,pnpm 任何一次写树都会在它上面撞车)原本以operation-error透传,
而它不在ERROR_PREFIXES里 → 套通用模板「请求没有完成 / 看宿主日志」,且客户端
errorCopy从不渲染diagnostic——唯一可操作的信息(EPERM + 具体路径)根本没显示。 - 修复(对齐 dsh-market
pnpm-compat.ts的windows-file-locked分类):新增
fileLockedDetail()识别EPERM/EACCES/EBUSY/operation not permitted/Access is denied,
命中时三段式文案切到专用键err.file-locked.*(「插件文件被占用 / 完全退出 DSH 后重试 /
目录损坏就先卸载再安装」),详情行露出 diagnostic 原文;可更新行内短句与已安装行错误
同步换成err.file-locked.row(「文件被 DSH 占用,退出后重试」)。 - e2e 把第二条
/install拦截成带 EPERM diagnostic 的失败:汇总必须写「成功 1、失败 1」,
失败行必须显示占用短句。
- 根因:宿主的
- 验收:
client-copy.test.mjs19/19(新增:占用识别三处接入、精简文案断言、
黑条删除断言);market-ui.e2e.mjs42/42(新增:写操作中无.dshpm-progress、
EPERM 失败行回执、汇总 1/1;截图前顺手关掉 scratch profile 的 API Key 引导弹窗)。
第三轮(修「点更新真的不会更新」+ 按钮状态机):
- 根因:更新从来就没换过版本(宿主行为 + pnpm 语义拼出来的死循环):
- 目录条目的
spec是裸 npm 名(dsh-context);pnpm 11 对「已存在的依赖 + 裸名」执行
pnpm add是幂等的——打印Already up to date、package.json一个字节不动。用 DSH 自带的
pnpm 11.7.0 在 scratch 目录复现:裸名 no-op,dsh-context@0.63.0才真的-0.62.3 +0.63.0。 - 宿主
installBundle在依赖名没变时仍按「本来就装着」回application: restart-required,
于是界面显示「已安装,重启后生效」而可更新角标一直是 2——点多少次都没用。 - 实证:用户 profile 的
package.json仍是dsh-context: ^0.62.3,16:39–16:40 的十几次
pnpm 操作日志全都是Already up to date。
- 目录条目的
- 修复一(宿主半):新增
pinnedNpmSpec——命中目录条目里的裸 npm 名在传给installBundle
前钉成name@version(版本字段像 semver 才钉;GitHub 类 URL、已带版本的 spec 原样放行),
POST /install的两条入口(按 name、按显式 spec)都走它。新增verify/install-spec.test.mjs
(源码不变量 + 本地目录 fixture + 假pluginManager的行为断言)。 - 修复二(客户端半):
noticeFromResult给每个分支标applied——restart-required/
overridden是「装好了」(warn 气泡照旧提示重启,但计成功),cancelled/ 无变更不算成功;
行内结果与批量汇总都按applied计数。截图里的「成功 0、失败 2」从此变成「成功 2、失败 0」。 - 按钮状态机(用户指定):页头合并成一颗「检查更新」——没检查过显示「检查更新」,按下重读列表,
检查过且有更新变成「一键更新(N)」,没有更新变成「重新检查」;页脚那颗独立的「重新检查」
合并删除(drawerFoot不复存在)。旁边那颗改名「插件市场更新」(「已是最新」三个字挨着插件的
「一键更新」只会误导),逻辑同型(检查 → 有更新给更新、没更新给重新检查),两颗按钮风格统一
(都带primary)。文案键:+action.checkUpdates,−action.selfCurrent、−action.updateAll。 - 验收:
client-copy.test.mjs21/21(新增:状态机与applied两组断言);
install-spec.test.mjs12/12;market-ui.e2e.mjs45/45(新增:初始「检查更新」、
点后变「一键更新(2)」、两颗按钮都带--primary、页脚drawerFoot为 0;第一条的注入结果
改成restart-required,必须计为成功并显示重启提示);release.ps1 -LocalOnly -Bump none
全绿;adversarial-host.mjs全过。e2e 这轮还顺手抓出一个写代码时引入的 bug:loadInstalled
无参调用时options是undefined,options.onDone抛错会把挂载时的首次回执整个吞掉。
性能审查(用户要求「检查插件问题并优化性能」):
- 根因:一个共享
tick让三个 effect 全部重跑。bumpTick()有 7 处调用(安装、卸载、
两个开关、刷新成功/失败、批量 finish),而/status、/catalog、/installed三个
useEffect都挂在同一个tick上——点一次「停用/卸载」= 3 个并发请求,其中两个是纯浪费:/status只回plugin.version+manager.available(宿主index.js里走catalog.peek()
零网络),两者只在启动时定下来,写操作绝不改变它 → 每次写操作重取一次,还把面板推回
loading相态闪一下骨架。/catalog要整份重抓 4412 条再过滤排序分页,而目录内容与安装/停用插件毫无关系。
- 修复:拆成两个独立计数器。
loadStatus()只在挂载时跑(依赖数组[]);目录 effect 依赖
新增的catalogTick,只有「刷新目录」的bumpCatalog()会推进它(且刷新后同时重读已安装,
因为updateAvailable依赖目录 join);已安装 effect 依赖installedTick,所有写操作的
bumpTick()只推进它。结果:一次写操作从 3 个请求降到 1 个(只/installed),刷新目录
才重抓目录。 - 审查过、确认无需改动的:列表 key 用稳定值(
item.id/bundle.name,非 index);
目录分页/过滤/排序在服务端(客户端不每次 render 重算 4412 条);join 用Map索引
(O(n+m),非 O(n·m));catalog10min TTL + in-flight 去重 + 失败 30s 冷却;目录计数徽标
模块级 5min TTL + inflight 复用;搜索 300ms 防抖;请求带 AbortController 且卸载时 abort;
noticeTimer正确清理;每小时定时器unref()。 - 验收:
client-copy.test.mjs22/22(新增一条源码不变量:loadStatus依赖数组为空、
目录 effect 依赖catalogTick、不存在共享[tick]);market-ui.e2e.mjs45/45;
release.ps1 -LocalOnly -Bump none全绿。
界面微调(用户截图反馈「俩个黑色按钮区分一下 还有刷新目录只用在发现界面有就行」):
-
两颗黑按钮文字撞车 → 分开。右键(市场本体)
ready态原先复用左键的
action.recheckSelf(「重新检查」),两颗挨着显示一样的字,分不清谁是谁。新增
action.recheckSelfOnly(zh「再查一次」/ en「Check market again」)只给右键用:
左键管插件的「重新检查」,右键管市场本体的「再查一次」。 -
「刷新目录」只在「发现」页签出现:已安装/可更新页没有目录列表,刷新没有意义。
header 里改成tab === "discover" ? 按钮 : null。 -
验收:
client-copy.test.mjs22/22(断言改为锁recheckSelfOnly与
tab === "discover"条件渲染);market-ui.e2e.mjs47/47(新增 2 条守卫:
切到已安装页后头部按钮数为 0、可更新页两颗按钮文案不同);
release.ps1 -LocalOnly -Bump none全绿。 -
右键文案再改一次:用户随后指定改成「插件市场更新」(原「市场更新检查」)。
action.checkSelf:zh「插件市场更新」/ en「Plugin market update」,并同步 21 处
代码注释与文档引用(README.zh.md、RELEASING.md、PLUGIN-MARKET.md、
API-CONTRACT.md、CHANGELOG.md)。 -
右键补成四态状态机(用户报「点不点都是一个」):这颗按钮此前只有
idle → checking → ready/installing 三态,且装完直接跳回 idle,点击后文字几乎不变。
按用户指定的四态补齐:状态 文案 初始(未点) 插件市场更新 点击后检查中 ...
v1.1.3
修「顶部提示条显示不全」——提示条被压成一条、文字只剩半行(用户截图就是这样)。
根因不在提示条本身,而在 CSS 的自动最小尺寸规则:.dshpm-root 是「定高 + 可滚动」的 flex 列,
直接子项默认 flex-shrink: 1;而 flex 项只要带非 visible 的 overflow(通知条为了底部那条
倒计时线加了 overflow: hidden),它的自动最小尺寸就变成 0。于是当内容比容器高时,
整段溢出量都被压到通知条身上:自然高度 36px 被压到 16px,自己的文字被裁掉一半。
其余区块没有这个问题,恰恰因为它们的自动最小尺寸等于内容高度、压不动。
- 修法:
.dshpm-root > * { flex: 0 0 auto; }——溢出由容器自己滚动消化,任何区块都不参与收缩。
(只给通知条写flex-shrink: 0也能好,但那样下一个带overflow的区块会再犯一次。) - 最小复现
verify/repro-notice-clip.html实测:修复前noticeClientHeight=16 < scrollHeight=36;
修复后36 == 36,且根仍可滚动(scrollHeight 1608 > clientHeight 620)。 - 回归断言进了真实浏览器验收(
verify/ui-check.ps1,24 → 29 条):提示条clientHeight ≥ scrollHeight
且高度 ≥ 30px、文案完整;并在**窄高视口(1200×520)**下断言「.dshpm-root的任一直接子项都没被压扁、
且根自己可滚动」——把这类缺陷从"看截图才发现"变成"自动化拦住"。
- versionName
1.1.3/ versionCode10103/ build+29.af4495d - 安装:
dsh plugin --profile web add <本页附件 dsh-plugin-market-1.1.3.tgz>(发布到 npm 后可直接add dsh-plugin-market) - 目录源以 npm 镜像优先(实测 289ms)对官方源(本机直连 25–93s 超时)
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md
v1.1.2
把「刚发布的版本看不见」和一条写错的根因一起修掉。
- 列表源会滞后,于是加了标签探测。实测(2026-10-04):发布一小时后,jsDelivr 的版本列表
仍然只有旧版本,@main的releases/index.json也被 CDN 缓存 12 小时——两个列表都说
「没有更新」,而真正的最新媒体早就在标签上了。现在列表都给不出更高版本时,按
MAJOR.MINOR.PATCH的常规递进探三个候选标签(下一个补丁 / 次版本 / 主版本);
任意标签是按需取的(刚推完@v<tag>/…立刻 200),所以这一层能追上滞后。有界:最多 3 次请求。 - 源顺序改为按新鲜度:GitHub Releases API 提到第一源(权威且最新),jsDelivr 两条居后。
被匿名限流(403,60 次/小时/IP)时只是这个源失败,继续问下一个;10 分钟缓存把点击量封在 ~6 次/小时。 - 下载多了第三条路:
@<tag>→@main→ GitHub Release 附件。前两条覆盖 CDN 的标签索引延迟,
第三条在 CDN 不可达时顶上。仍然只有传输层失败才换路,拿到字节后哈希不符直接硬失败。 - 更正一条写错的根因(这条要单独说)。1.1.0 的更新日志里写着「本机直连
api.github.com的真实路径
一律 403(GFW 拦截)、github.com被重置,需要 Clash 代理」。这是错的,两个原因叠在一起:
当时仓库还是 private(未鉴权取releases/latest就是 404),而匿名限流返回的 403 被误读成封锁。
2026-10-04 实测:API 200(X-RateLimit-Remaining: 47/60)、github.com200、
Release 附件直连 200(2.7s;走代理 737ms),下载字节的 sha256 与本地构建一致;
git ls-remote(842ms)与gh也不需要代理。代理不是必须的,只是更快/更稳。
文档里所有相关表述(docs/RELEASING.md§3/§4.4、两份 README)已一并更正。
- versionName
1.1.2/ versionCode10102/ build+25.3204c30 - 安装:
dsh plugin --profile web add <本页附件 dsh-plugin-market-1.1.2.tgz>(发布到 npm 后可直接add dsh-plugin-market) - 目录源以 npm 镜像优先(实测 289ms)对官方源(本机直连 25–93s 超时)
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md
v1.1.1
修两处只有真的发一次版才会暴露的问题(发布 v1.1.0 后立刻实测到的)。
- 一个源半残不再让整次检查失败。jsDelivr 刚拿到新标签时,它的版本列表可能还是旧的
(实测:推完 v1.1.0 后列表里只有 1.0.0–1.0.2),而那些版本没有releases/目录,
于是第一个源的清单 404 → 旧实现直接回「找到了最新版本 v1.0.2,但拿不到它的发布清单」,
尽管@main上的清单已经是 1.1.0。现在每个源必须版本与该版本的清单都拿到才算成功,
失败就继续问下一个;已经有一个明确更高的答案时早退出(一次点击不必问遍所有源),
没有更新时问完全部源再取最高版本——不同源的缓存新鲜度不一致,取最高才不会漏更新。 - 下载不再只认标签地址。标签可能还没被 CDN 索引(实测
@v1.1.0/…一度 404),
现在按「标签地址 → main 分支的同一路径」依次尝试。内容由sha256负责,从哪条路取都不影响安全性;
但只有传输层失败才换路——字节都拿到了却哈希不符是篡改信号,直接硬失败,不再试别的来源。 - 顺带修掉一个测试卫生问题:「没有 fetch 的运行环境」那条断言在 Node 里其实退回了
globalThis.fetch,于是它会真的去打网络,网络一通就误判为通过。现在真的把全局fetch摘掉再测。
新增 verify/self-update-live.ps1:把 package.json 临时降到一个低于最新标签的版本,
在 scratch profile 里真的走一遍「有更新 → 下载 → 三道校验 → pnpm 安装」,结束时按字节还原
(实测 6/6 通过)。
- versionName
1.1.1/ versionCode10101/ build+23.389a43d - 安装:
dsh plugin --profile web add <本页附件 dsh-plugin-market-1.1.1.tgz>(发布到 npm 后可直接add dsh-plugin-market) - 目录源以 npm 镜像优先(实测 289ms)对官方源(本机直连 25–93s 超时)
- 变更与验收细节见仓库 docs/ 与 verify/REPORT.md