Skip to content

Releases: Winnie-0721/dsh-plugin-market

v1.3.2

Choose a tag to compare

@github-actions github-actions released this 19 Oct 05:58
  • 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.json 7,198,658 B(6.87 MiB)✅ 本身没超

    顶穿它的是同一个包里我们从不读的 package/index.json(2,320,845 B)。
    上游 2026-10-18 2026.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_BYTES 8 MiB → 16 MiB,与压缩侧上限同档。
    炸弹防护意图完全不变(32 MiB 测试炸弹仍被 maxOutputLength 挡在分配之前),
    只是把一个按「当时 5 MB 目录」拍的数,改成与压缩侧同一量级。

    实测(真跑 cache.ensure()):

    修复前 修复后
    结果 502 catalog-unavailable,42.2s ok,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 / versionCode 10302 / 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

Choose a tag to compare

@github-actions github-actions released this 18 Oct 11:08
  • 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_call to 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.yml publish-npm.yml ✅
    v1.3.0 标签 → pack-release.yml → workflow_call pack-release.yml ❌

    修法:publish-npm.yml 自己监听 push: tags: ['v*'],与 pack-release.yml
    并列触发、互不调用(一个发 GitHub Release,一个发 npm);删掉 pack-release.yml
    里那个注定失败的 publish-npm job,并去掉 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_call publish-npm.yml
    ,且 publish-npm.yml 必须自己监听标签。
    门禁里的这些断言只查生效行、不查注释——pack-release.yml 的注释里刻意写着
    「本文件也不去调用 publish-npm.yml」来解释原因,全文正则会把这句解释判成违规。


  • versionName 1.3.1 / versionCode 10301 / 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

Choose a tag to compare

@github-actions github-actions released this 18 Oct 10:54
  • fix:更新永远失败(「宿主拒绝了这个操作」)——更新改走 tarball 形态,绕开宿主的同名拦截。

    用户报的:点「更新到 x.y.z」→ 宿主返回 already-installed,界面只说「宿主拒绝了这个操作。」。
    实测两个可更新包(dsh-web-search-free 1.8.0→1.8.1、dsh-mobile 0.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 的注释里写清了):

    1. 只在 kind === 'update' 时改写,首次安装仍按目录给的 spec 走;
    2. 必须在校验 spec 落在目录里之后,否则这里就成了任意包安装器;
    3. 版本必须像 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):点开详情改成弹出居中对话框**(用户要求,参照会话清理的「确认删除」框)。**

    形态演进,别再退回前两版:

    1. 内嵌在卡片里 → 被塞进 232px 网格窄列,截图/红线长句挤成一坨、整行被撑高(「展开太丑」)。
    2. 跨整行独立卡片 → 宽度够用了,但它在文档流里:展开会把下面的内容整体顶下去,
      用户视线要跟着滚,关掉又跳回来。
    3. 现在:居中对话框 → 浮在页面上,列表位置完全不动,进出都不丢上下文。

    形态与语义照 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…):

    字段 条数 之前 现在
    capabilityRedLines 5275(597 条非空) 完全不显示 详情页「风险提示」
    capabilityCheckedAt 5275 完全不显示 显示检测时间
    screenshots 1289(3650 张) 完全不显示 详情页缩略图
    tarball 581 完全不显示 详情页 + 安装命令兜底
    downloadsStart/End 2947 完全不显示 下载量的时间口径
    downloadsCheckedAt 2947 完全不显示 —

    最重要的一条是红线。 实测里最该让用户看到的是
    reads credentials/secrets AND has network access —— 481 条(读凭据 + 能联网 = 可能外传),
    还有 runs code at install time (postinstall) 22 条、指向云元数据 169.254.169.254 的 7 条。
    市场以前一条都不显示。红线文案原样透传、不翻译:它是检测器的原话,改写会让用户
    以为是我们自己验证过。

    三条不能读错的语义(都照上游注释原文落实):

    1. 「缺失」≠「干净」。上游:「an entry absent here was NOT scanned — the surfaces print
      that as 未检出, never as clean.」实测 capabilities 与 capabilityRedLines 是一起出现/
      一起缺失
      的(都 5275 条),缺的 511 条是没扫过。所以内部用三态区分:
      null = 未扫描、[] = 扫过没发现、非空 = 扫过有内容。界面上「未扫描」有明确文案
      (「未扫描(目录没有这个插件的能力检测记录,不代表安全)」),绝不渲染成空白或绿色。
      类型不对(字符串、数字)也判「未扫描」而不是「扫过、没问题」——把坏数据当成安全是在背书。
    2. 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」。
    3. 截图只许 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 白名单、null vs 0、坏类型宽容、以及「新字段不能破坏既有搜索/排序」)。

    顺带发现并修掉一个早已存在的语义错误:下载量把 null 折成 0(见第 2 条)。
    它不是这次升级引入的,是这次对齐上游语义时才暴露出来的。

  • 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 分支):那会破...

Read more

v1.2.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 13:33
  • 桌面端为什么没有一键重启按钮:我原来的理由不完整,查清后把真理由写进契约;顺带修掉
    「刷新页面就丢掉待重启提示」
    (用户第六、七轮:先问「你桌面端为什么不做重启按钮」,
    再问「是默认生效的吗」)。

    先纠正我自己。我原先写的理由是「替代进程会被单实例锁挡下」——那只是第一层。
    读完壳的 lib/main.js(490KB)后,事实更硬,且三条各自独立成立:

    1. 没有「请壳重启自己」的通道。宿主→壳的 IPC 消息是封闭白名单,
      壳只认 ready / platform-session / shutdown-complete / fatal 四种;
      其余一律 fail("invalid IPC event") 并 SIGTERM 掉子进程(main.js:3698-3717)。
      「礼貌地请求重启」连消息类型都不存在。
    2. 宿主一退出,壳当致命故障处理,且不会自动拉回来:child.once("close") 里
      code !== 0 与 code === 0 都走 fail(...)(main.js:3721-3728)→ 弹原生
      「启动失败」对话框。自动重启只发生在壳自己的更新流程里(restoreHost 分支)。
    3. 那个对话框里有一颗破坏性按钮:[退出应用, 重启应用, 停用第三方插件],
      第三颗走 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 逐字节一致。

    本轮记三条我自己的错(都写进注释,避免下次再犯):

    1. 模板字符串里写了反引号(连注释里也不行)。它提前结束模板,表达式被切成字符串拼接,
      跑起来报 restartBtn is not a function——与现场毫不相干,而 node --check 是过的,
      只有真跑才暴露。
    2. 断言站错地方:刷新后我只等 .dshpm-entry 就去查 .dshpm-banner,但面板默认是关的、
      横幅不在 DOM 里。而且我第一版等的是 .dshpm-restartBtn——桌面端 restart.available=false
      时那个按钮故意不存在。两处都是「测空气」,跟功能是否生效无关。
    3. 改一个被「抠出来执行」的函数,要连带看谁在抠它:sendChangeResult 新增一句记账调用后,
      build-approval.test.mjs(它在裸作用域里 new Function(抠出的源码))当场 ReferenceError
      假红——缺 noteRestartFromResult 与它读写的 restartPending。门禁在改完当场抓住了它。
  • 开关 ↔ 重启:确认有一处真冲突,并修掉(用户第五轮问「检查开关和重启是不是有冲突」)。

    先回答「冲突在哪」——不是「写了同一个文件」,而是粒度不一致:

    动作粒度 落盘
    开关 文件级 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-only PASS;
    ② 模拟 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 复现,
    修前修后都跑了变异测试)。

    1. 「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 稳定复现。缓存会让错误结论看起来可复现。

    2. 「供应链策略拦下」被误报成「连不上 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...

Read more

v1.1.6

Choose a tag to compare

@github-actions github-actions released this 07 Oct 06:39
  • 核心逻辑审计修复:身份匹配、内容校验、错误归类三类真实缺陷(用户要求「检查核心代码
    是否有逻辑错误」并按「现代应用市场的逻辑」修复):三份独立审计(catalog / self-update+http /
    host 路由)共报 26 条,我逐条在真实 4412 条目录快照上复核——复核否掉了 3 条(排序并列组
    数量统计错误、搜索「跨字段命中换行」未复现、page>pages 属契约允许),其余按严重度修掉:

    1. 目录身份层(会装错包,最高优先):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,一个键落到两个不同条目时
      标记歧义、不再匹配
      (原来是「先到先得」,哪条被标成已安装纯看文件顺序)。
    2. 内容闸门(市场可能被静默清空):validateCatalogPayload 只查类型,于是
      {count:4412, plugins:[]}(源站故障/截断)会被判为合法并以 stale:false 覆盖好缓存——
      市场整个变空、分类 chips 消失,而且因为不是 stale 连「缓存可能过期」横幅都不显示,
      看起来像「真的一共 0 个插件」。现在交叉核对 plugins.length 与 count(1% 容差)。
    3. 主用 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 由「有则校验」改为必填:元数据本身也来自网络,
      只信「元数据说没问题」等于没校验,缺字段即整源失败(与文件头声明的信任模型一致)。
    4. 搜索:url 曾在搜索 haystack 里,于是搜 github 命中全部 4412 条(几乎每条都有
      github 地址),用户以为搜到了什么其实全量;字段间用 \n 连接还导致跨字段子串命中。
      现在只搜 UI 承诺的字段(名字/作者/描述/能力),多词是「与」语义并按字段匹配;另加
      NFKD 去变音符号(jose 能搜到 José,实测目录里 25 条描述带重音)。
    5. 分页与排序:paginate 在 page 超出末页时收敛到末页(原返回 page=5/pages=2/items=[],
      界面显示「第 5/2 页」+ 空网格且不会自愈——客户端只在改搜索条件时重置页码);sortPlugins
      追加 id 兜底键保证全序(真实数据有 171 组完全并列,原来是「谁先来谁在前」,
      刷新源顺序就会换页位)。
    6. 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 是字符串)。
    7. 重启链路:restart 端点改为先安排退出、再写响应(原顺序在响应写失败时——客户端切走/
      代理断开——异常被外层吞掉,退出永不安排,而 restart.js 已置 requested,之后每次点击都回
      already:true 并跳过安排 → 「重启 DSH」永久失效);restart.js 把 child.on('error')
      提到 pid<=0 那条提早 return 之前(spawn 的 ENOENT 是异步事件,未处理的 'error'
      会直接终止宿主进程,用户看到 500 之后进程就没了),并在该路径顺手 unref。
    8. 自更新通道(两条「谎称已是最新」):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。
    9. 客户端:目录过期横幅的原因永远是「原因未知」(服务端 /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.mjs 26→30/30、self-update.test.mjs 39→43/43。
      这一批审计同时留下一条方法论记录:三份审计共 26 条结论里 3 条经复核不成立,不能照抄。
  • 上一版修复的对抗性复核:抓出并修掉 4 条我自己引入/残留的缺陷(把 diff 交给独立一方
    专门找错,而不是自己再读一遍)。这一轮全部由可复现的证据驱动:

    1. 「一键更新」会永久卡死(最严重,是我上一版引入的):新加的「同一个包正在装就吞掉重复
      点击」守卫只 return 不回调,而一键更新的顺序执行靠回调推进——用户在「已安装」页插进
      一个正在被批量处理的包时,那一步既不发请求也不回调 → 循环断掉、batch 永远 {running:true}、
      按钮被自己的守卫挡住,整个会话内一键更新彻底失效、只能刷新页面。
      根因还有一处不一致:「已安装」页的更新按钮原先不像「可更新」页那样受 batchRunning 约束,
      正好从那一页漏过去。修法三条一起上:守卫命中时也回调(并在批量里把这类记为 skipped,
      既不算成功也不算失败)、已安装页按钮补上 batchRunning、新增「{name} 正在装/更新,已跳过。」文案。
    2. 「取消安装」的文案是死路径:ok 判成 error === null && application !== 'failed',
      于是宿主「取消 + 带一个说明原因的 error」时 ok=false,客户端 requestJSON 直接抛错,
      notice.*Cancelled 永远渲染不出来。现在 cancelled 恒为 ok=true(无论有没有 error)。
    3. 过期原因仍有一半路径是「原因未知」:上一版只让客户端认字符串错误码,但 /catalog
      响应里根本没有 error 字段,而客户端优先读的正是它——只有 /status 那条半路径能拿到。
      现在 /catalog 也带 error: cache.error ?? null。
    4. 搜索与身份的过度修复(两条都是我上一版用力过猛):
      • 为了让「搜 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 对不上。
    5. 内容校验收紧了但要给手工源留活路:1% 容差对 count=10/实际 13 这类手工
      DSHM_REGISTRY_URL 目录过严(整源被拒),改成小目录(≤100)给 5 条余量、大目录按 2%。
    6. unverified 去重:多个源报同一个版本时诊断文案里出现「1.1.6、1.1.6」。
    7. 注释的论证站不住:重启端点「先排退出再写响应」这个顺序是对的(防御性),但我原先写的
      因果链(「响应写失败 → 异常被吞 → 退出永不安排」)经实测缺少证据——对已 destroy 的 socket
      写并不抛错,只有对已 end 过的 res 才会同步抛 ERR_STREAM_WRITE_AFTER_END。注释已改成诚实版本。
      本轮同时把两条只 grep 源码形状的断言换成了真调 handler 读响应体的行为测试
      (sendChangeResult 六种 application/error 组合),因为形状断言换个等价写法就会假红/假绿。
      host-contract.test.mjs 16/16、catalog-identity.test.mjs 36/36、client-copy.test.mjs
      30/30、self-update.test.mjs 43/43、门禁与 e2e 64/64 全绿。
  • 补测:把「全绿」里仍存在的空洞补上。复核发现上面那批修复的回归覆盖并不完整,补了三处:

    1. 「一键更新永久卡死」的入口在真实浏览器里补了一条回归(e2e 64→65/65):批量进行中切到
      「已安装」页,断言那颗更新按钮确实被禁用。这是当时唯一漏掉的入口——「可更新」页的行内按钮
      有 batchRunning、这一页没有,复核脚本正是据此认定「真实用户可达」。现在由真实浏览器兜住,
      而不只是源码里 grep 到那个字符串。
    2. findCatalogItem 改成真调(host-contract 16→18/18):身份层(id/npm/url)命中
      即确定;显示名层唯一才确定、重名报歧义且 item === null(绝不返回其中任意一个)。
      这是「会装错包」那一类里唯一还只剩源码形状断言的地方。
    3. 线上通道体检(临时探针,跑完即删、不入库):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/ 里的断言分两种价值——真调行为(能抓住回归)与源码形状
    (只证明那个字符串还在文件里,换个等价写法就会假红/假绿)。这批修复里凡是「会装错包 / 会卡死 /
    会谎报」的高危路径,都已经至少有一条真调或真实浏览器的断言。

  • 三个页签页面统一间距与高度,新增页面只改一处注册表(用户报:发现 / 已安装 / 可更新
    三个页面「高度不对齐」,并要求「设计成统一的,方便后续添加页面」):根因是三页各写各的
    外壳——发现页是「工具条 + 分类 + 汇总 + 网格」,已安装页是「汇总 + 行列表」,
    可更新页却是一张带边框和内边距的卡片(.dshpm-updatesPanel 的 padding:12px 14px),
    那 12px 内边距把这一页的第一行文字整体推下去,加上容器节距有 8 也有 10,切页签就能看到
    内容上下跳。修法三层:

    1. 统一外壳:新增 .dshpm-page,渲染处把当前页套进去,三页共用一套节距(12px)
      与起点;.dshpm-updatesPage 作为整页时去掉卡片内边距与边框(基类留着,便于别处复用)。
      页面级间距统一成一个单位 12px(gap),卡片/列表行内部仍是 8px(块内间距)。
      .dshpm-page 用 flex:1 0 auto 覆盖 .dshpm-root > * 的 0 0 auto:内容不足一屏时
      页面撑满剩余高度(几页等高),内容超长时仍由面板根自己滚动、任何区块都不收缩
      (§12.12 的不变量不变,没有引入第二个滚动容器)。
    2. 固定页签高度:.dshpm-tab 改 inline-flex + min-height:32px,带角标(可更新)
      与不带角标的页签一样高——后续新增页面加角标、加图标都不会把页签栏撑高。
    3. 页签/页面注册表:新增 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-npm job(版本已在 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...

Read more

v1.1.5

Choose a tag to compare

@Winnie-0721 Winnie-0721 released this 05 Oct 17:33
  • 首次进入「可更新」页不再显示「再次检查」(用户报:第一次进入这个界面怎么会是再次检查的
    状态机,应该是检查商店更新):根因是启动时那次自动检查把模块级状态机推到了 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 / versionCode 10105 / 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

Choose a tag to compare

@Winnie-0721 Winnie-0721 released this 05 Oct 14:00

包名变更:dsh-market → deepseek-harness-market(前序:dsh-plugin-market → dsh-market)。

两次都是被 npm 规则挡回来的,如实记录:

  1. dsh-plugin-market 已被另一个项目占用(fireguo / veloce-ailab,latest 1.3.0);
  2. 改用的 dsh-market 被 同名规则拦下——与已存在的 dshmarket(v1.66.8)只差一个连字符,
    registry 返回 403 Package name too similar to existing package dshmarket,--access public
    也绕不过。registry 建议加 scope,但用户名 winnie_0721 含下划线、scope 不允许下划线;
  3. 最终选定 deepseek-harness-market,实测可用且近似名(deepseekharnessmarket、
    deepseek-market 等)均无冲突。

影响范围:

  • package.json name、cordis.patch.yml patch name、lib/index.js PLUGIN_NAME、
    lib/client.js bundle id / SELF_NAME / STYLE_ID、lib/self-update.js MARKET_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 的历史验收记录
    不改写,只加一条路径后记(证据必须留在它当时的上下文里)

「更新插件」按钮补上反馈(用户报:点它没有任何反应,而旁边的「检查市场更新」有)。

两个根因,都修了:

  1. 面板开在视区外——可更新面板在 DOM 里排在目录卡片网格之后,点开时它在几百像素之下,
    看上去就是「什么都没发生」。现在展开后 scrollIntoView({ block: "nearest" }) 滚进可视区,
    用户偏好减少动效时直接跳位、不放平滑滚动。
  2. 回执只在「有更新且首次」时才发——现在与「检查市场更新」一个待遇:按钮进入
    「检查更新中…」忙碌态(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

头部按钮大扫除 + 自动检查 + 一键更新(用户这一轮提的一整条需求):

  1. 右上角只留「刷新目录」:删掉「更新插件」(第三个页签与角标取代了它)与「检查市场更新」
    (搬进可更新页页头)。两个按钮的反馈原样搬过去——忙碌态 spinner + aria-busy + 回执气泡;
    相关文案 action.updates / action.checkingUpdates 随之删除(不留僵尸键)。
  2. 自动检查规则(startUpdateScheduler,在 apply 里启动、schedulerStarted 守卫):
    • 每次启动 DSH → 检查一次市场本体更新(GET /self-update,宿主 10 分钟缓存),结果写模块级
      状态机:可更新页的按钮一打开就是检查结果;
    • 启动后每 1 小时 → 检查一次插件更新(GET /installed,绕过 5 分钟计数 TTL),刷新角标;
      只有「多出新更新」且市场页开着时才补一条回执,否则不打扰;
    • 定时器 unref()、回调包 try/catch:否则 style-heal.test.mjs 这类会调 apply 的回归测试
      会被每小时的定时器拖住永远不退出(实测卡死后定位到)。
  3. 可更新页页头的两个按钮:
    • 「检查市场更新」(四态状态机,从头部搬来,启动那次自动检查已经写过它);
    • 「一键更新(N)」(新):按顺序逐个执行同一条安装接口,逐条结果留在该行,跑完给一条汇总回执;
      撞上「要先批准构建脚本」就暂停并把决定权交回用户。逐条的「更新到 x.y.z」保留。
    • 顺手修掉一处自相矛盾:无更新时回执改成说本机版本——此前用远端 latest,会出现
      「标题写着 1.1.4、提示却说已是最新 v1.1.3」(用户截图里就是这个)。
  4. 回执气泡再进一步:入场换成带回弹的曲线、毛玻璃(backdrop-filter)+ 分层投影;新增退场——
    先翻 data-open="false" 下沉 200ms(NOTICE_CLOSE_MS)再卸载,不再凭空消失;reduced-motion 下
    照旧立即消失、内容不受影响。
  5. 文案:notice.updatesFound 不再让人去点已删除的按钮;updates.hint 说明两种跑法。
  6. 验收:client-copy.test.mjs 17/17(新增:头部只剩刷新目录、两个新按钮的反馈、自动检查
    规则、退场动画);market-ui.e2e.mjs 40/40(批量那两次 /install 由 CDP 拦成确定性成功,
    不去打宿主的目录校验)。顺带修掉 e2e 失败时一行输出都没有的坑:process.exit(1) 会把管道里
    还没刷出去的 console.log 全吞掉——改用 process.exitCode = 1。

第二轮截图反馈(toast 精简 / 删黑条 / 修更新失败的回执):

  1. 标题上方的黑条进度条删掉(用户点名):.dshpm-progress 节点与样式全部移除,写操作的
    「进行中」改由触发它的按钮表达(spinner + aria-busy + 禁用),进度感不再横在标题上。
    e2e 新增断言:批量进行中(按钮 aria-busy=true 的窗口)页面里 .dshpm-progress 必须为 0。
  2. 回执文案精简(用户点名「toast 尽量精简」):
    • 目录已刷新,共 4412 个插件 → 已刷新 4412 个插件
    • 发现 2 个插件有新版本,可逐个「更新到 x.y.z」或点「一键更新」。 → 2 个插件有新版本。
    • 已检查 15 个插件:全部都是最新版本。 → 全部都是最新版本。
    • 一键更新完成:成功 2 个、失败 0 个。 → 更新完成:成功 2、失败 1。
    • 英文侧同步精简;e2e 相应正则改写。
  3. 更新失败的回执翻译成人话(用户报「点更新会失败」,先查了 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」,
      失败行必须显示占用短句。
  4. 验收:client-copy.test.mjs 19/19(新增:占用识别三处接入、精简文案断言、
    黑条删除断言);market-ui.e2e.mjs 42/42(新增:写操作中无 .dshpm-progress、
    EPERM 失败行回执、汇总 1/1;截图前顺手关掉 scratch profile 的 API Key 引导弹窗)。

第三轮(修「点更新真的不会更新」+ 按钮状态机):

  1. 根因:更新从来就没换过版本(宿主行为 + 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。
  2. 修复一(宿主半):新增 pinnedNpmSpec——命中目录条目里的裸 npm 名在传给 installBundle
    前钉成 name@version(版本字段像 semver 才钉;GitHub 类 URL、已带版本的 spec 原样放行),
    POST /install 的两条入口(按 name、按显式 spec)都走它。新增 verify/install-spec.test.mjs
    (源码不变量 + 本地目录 fixture + 假 pluginManager 的行为断言)。
  3. 修复二(客户端半):noticeFromResult 给每个分支标 applied——restart-required /
    overridden 是「装好了」(warn 气泡照旧提示重启,但计成功),cancelled / 无变更不算成功;
    行内结果与批量汇总都按 applied 计数。截图里的「成功 0、失败 2」从此变成「成功 2、失败 0」。
  4. 按钮状态机(用户指定):页头合并成一颗「检查更新」——没检查过显示「检查更新」,按下重读列表,
    检查过且有更新变成「一键更新(N)」,没有更新变成「重新检查」;页脚那颗独立的「重新检查」
    合并删除(drawerFoot 不复存在)。旁边那颗改名「插件市场更新」(「已是最新」三个字挨着插件的
    「一键更新」只会误导),逻辑同型(检查 → 有更新给更新、没更新给重新检查),两颗按钮风格统一
    (都带 primary)。文案键:+action.checkUpdates,−action.selfCurrent、−action.updateAll。
  5. 验收:client-copy.test.mjs 21/21(新增:状态机与 applied 两组断言);
    install-spec.test.mjs 12/12;market-ui.e2e.mjs 45/45(新增:初始「检查更新」、
    点后变「一键更新(2)」、两颗按钮都带 --primary、页脚 drawerFoot 为 0;第一条的注入结果
    改成 restart-required,必须计为成功并显示重启提示);release.ps1 -LocalOnly -Bump none
    全绿;adversarial-host.mjs 全过。e2e 这轮还顺手抓出一个写代码时引入的 bug:loadInstalled
    无参调用时 options 是 undefined,options.onDone 抛错会把挂载时的首次回执整个吞掉。

性能审查(用户要求「检查插件问题并优化性能」):

  1. 根因:一个共享 tick 让三个 effect 全部重跑。bumpTick() 有 7 处调用(安装、卸载、
    两个开关、刷新成功/失败、批量 finish),而 /status、/catalog、/installed 三个
    useEffect 都挂在同一个 tick 上——点一次「停用/卸载」= 3 个并发请求,其中两个是纯浪费:
    • /status 只回 plugin.version + manager.available(宿主 index.js 里走 catalog.peek()
      零网络),两者只在启动时定下来,写操作绝不改变它 → 每次写操作重取一次,还把面板推回
      loading 相态闪一下骨架。
    • /catalog 要整份重抓 4412 条再过滤排序分页,而目录内容与安装/停用插件毫无关系。
  2. 修复:拆成两个独立计数器。loadStatus() 只在挂载时跑(依赖数组 []);目录 effect 依赖
    新增的 catalogTick,只有「刷新目录」的 bumpCatalog() 会推进它(且刷新后同时重读已安装,
    因为 updateAvailable 依赖目录 join);已安装 effect 依赖 installedTick,所有写操作的
    bumpTick() 只推进它。结果:一次写操作从 3 个请求降到 1 个(只 /installed),刷新目录
    才重抓目录。
  3. 审查过、确认无需改动的:列表 key 用稳定值(item.id / bundle.name,非 index);
    目录分页/过滤/排序在服务端(客户端不每次 render 重算 4412 条);join 用 Map 索引
    (O(n+m),非 O(n·m));catalog 10min TTL + in-flight 去重 + 失败 30s 冷却;目录计数徽标
    模块级 5min TTL + inflight 复用;搜索 300ms 防抖;请求带 AbortController 且卸载时 abort;
    noticeTimer 正确清理;每小时定时器 unref()。
  4. 验收:client-copy.test.mjs 22/22(新增一条源码不变量:loadStatus 依赖数组为空、
    目录 effect 依赖 catalogTick、不存在共享 [tick]);market-ui.e2e.mjs 45/45;
    release.ps1 -LocalOnly -Bump none 全绿。

界面微调(用户截图反馈「俩个黑色按钮区分一下 还有刷新目录只用在发现界面有就行」):

  1. 两颗黑按钮文字撞车 → 分开。右键(市场本体)ready 态原先复用左键的
    action.recheckSelf(「重新检查」),两颗挨着显示一样的字,分不清谁是谁。新增
    action.recheckSelfOnly(zh「再查一次」/ en「Check market again」)只给右键用:
    左键管插件的「重新检查」,右键管市场本体的「再查一次」。

  2. 「刷新目录」只在「发现」页签出现:已安装/可更新页没有目录列表,刷新没有意义。
    header 里改成 tab === "discover" ? 按钮 : null。

  3. 验收:client-copy.test.mjs 22/22(断言改为锁 recheckSelfOnly 与
    tab === "discover" 条件渲染);market-ui.e2e.mjs 47/47(新增 2 条守卫:
    切到已安装页后头部按钮数为 0、可更新页两颗按钮文案不同);
    release.ps1 -LocalOnly -Bump none 全绿。

  4. 右键文案再改一次:用户随后指定改成「插件市场更新」(原「市场更新检查」)。
    action.checkSelf:zh「插件市场更新」/ en「Plugin market update」,并同步 21 处
    代码注释与文档引用(README.zh.md、RELEASING.md、PLUGIN-MARKET.md、
    API-CONTRACT.md、CHANGELOG.md)。

  5. 右键补成四态状态机(用户报「点不点都是一个」):这颗按钮此前只有
    idle → checking → ready/installing 三态,且装完直接跳回 idle,点击后文字几乎不变。
    按用户指定的四态补齐:

    状态 文案
    初始(未点) 插件市场更新
    点击后检查中 ...
Read more

v1.1.3

Choose a tag to compare

@Winnie-0721 Winnie-0721 released this 04 Oct 10:51

修「顶部提示条显示不全」——提示条被压成一条、文字只剩半行(用户截图就是这样)。

根因不在提示条本身,而在 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 / versionCode 10103 / 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

Choose a tag to compare

@Winnie-0721 Winnie-0721 released this 04 Oct 10:21

把「刚发布的版本看不见」和一条写错的根因一起修掉。

  • 列表源会滞后,于是加了标签探测。实测(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.com 200、
    Release 附件直连 200(2.7s;走代理 737ms),下载字节的 sha256 与本地构建一致;
    git ls-remote(842ms)与 gh 也不需要代理。代理不是必须的,只是更快/更稳。
    文档里所有相关表述(docs/RELEASING.md §3/§4.4、两份 README)已一并更正。

  • versionName 1.1.2 / versionCode 10102 / 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

Choose a tag to compare

@Winnie-0721 Winnie-0721 released this 04 Oct 10:16

修两处只有真的发一次版才会暴露的问题(发布 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 / versionCode 10101 / 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