FeatherHunter/dsh-plugin-update

dsh-plugin-update

给 DSH 插件加「检查更新 / 安装更新」能力的 npm 包:宿主一段接线 + 面板一个现成组件,查新版、装更新、失败兜底、更新日志与多插件日志一次包掉

Uncategorizedchangelogdshdsh-log

dsh-plugin-update

✨ 本包为任何 DSH 插件提供开箱即用的自我更新能力:宿主侧一段接线,面板侧挂一个现成组件,把「查新版 / 装更新 / 失败兜底 / 更新日志」一次性包掉,为开发者省下大量重复工作量与长期维护成本。直接把本 README 丢给 AI 通读即可集成:第 1–2 节是三步接入法,后续章节是全部 UI 控件(单面板 / 批量面板 / 入口件 / 批量入口件 / HTTP 直挂)与自定义 UI 参数,基本都支持按需覆盖。
📦 优秀示例项目:dsh-mattpocock-skills-deck · dsh-opencode-palette · ilife

给 DSH 插件加「检查更新 / 安装更新」能力的 npm 包。宿主侧一段接线,面板侧挂一个现成组件,装不上时给用户一条可复制的手工命令。

要求 Node 22 或更高。运行期依赖只有一个:dsh-log ^0.3.4(0.16.0 起引入,0.16.4 起下限抬到 0.3.4,见第 10 节)。当前版本见上方徽章与 package.json(此处不写死版本号,免得跟着发布漂)。

装上它你会拿到七样东西:

  • 六个电话:查状态(只读本地)、查新版(用户点了才联网一次)、装更新(拿凭证提交)、取日志(按版取更新日志,全文或空)、交日志(面板把攒下的日志行交回宿主落盘,0.16.0 起)、查合规版(最新版还在年龄门内时,回「满门限的那个版本是哪个」与它的候选凭证,0.18.6 起)。电话指宿主对外提供的方法。
  • 一套落盘:任务状态、安装锁、回滚凭据,按「插件标识 + 使用范围」隔离,多插件互不干扰。
  • 面板要的派生取值:电话名与轮询间隔,构建期从本包生成,面板里不写死。
  • 一个现成整组件:默认内嵌、可切弹窗,调用者传参指定;轮询、安装门控、中文一句话、待重启横幅、手工命令展示与复制、排队可见开关、跳过与恢复、诊断一键复制、更新日志自动展示全在组件内部消化。
  • 一个更新入口件:配置页一行挂上就是「检查更新」按钮(或只给状态点的徽标、或整块内嵌),按钮文案随状态自己变;见第 2.5 节。
  • 一个安装按钮控件:任意卡片里原地装好一家插件(缺席可装:未装显示「未安装 → 目标版本」,点下先查后装);判定、文案、诊断与面板同一口径,失败兜底手工命令;见第 2.8 节。
  • 一套多目标批量更新:一个插件管 N 个插件的更新(总账 + 明细 + 动作,一家收尾才起下一家,会话落盘可断点续跑,进程级更新中枢让自更新不断链、全局进度可只读查询);见第 2.6 节。

1. 安装

npm install dsh-plugin-update

把本包装成你自己插件的依赖即可(推荐);老式的「把本包 vendor 进插件目录」也照旧可用。 两种形态下,包都按包名在自己的 node_modules 链上找你的插件包——找到它,才能推出「插件装在哪、正在跑的是哪一版」。

包还没公开发布时先用本地路径代替(例如 npm install ../dsh-plugin-update)。

2. 三步接入

下面假设你的插件标识是 my-notes-plugin、电话名前缀是 notes、要查更新的包也是 my-notes-plugin。 三处名字都换成你自己的;prefix 不传即 wf(历史取值),新插件务必传自己的前缀,多插件靠它隔离。

第 1 步:装包

见第 1 节。

第 2 步:宿主侧接线

import { createHostUpdate } from 'dsh-plugin-update'

const update = createHostUpdate(
  { ctx, logCtx },
  {
    pluginId: 'my-notes-plugin',
    prefix: 'notes',
    targetPackageName: 'my-notes-plugin',
  }
)
// 把处理器按键注册进自己的电话表;电话名从 update.phoneNames 读,不要自己拼字符串。
for (const [name, handler] of Object.entries(update.handlers)) {
  registry.set(name, handler)
}

这一步得到六个电话名:notes.updateStatus、notes.updateCheck、notes.updateInstall、notes.updateChangelog(取日志:给版本号,回该版 tarball 里的更新日志全文,取不到回空)、notes.logAppend(收面板侧的日志行,见第 10 节;0.16.0 起新增)、notes.updateCheckFallback(合规版决议:最新版还在年龄门内时装不上,问它「满门限的最新版是哪个」,0.18.6 起新增)。另外还有一通只读全局进度的 notes.updateHubStatus(hubStatus 字段给出全名,见第 2.6 节末尾),与批量宿主那通同语义。

pluginId 必填(非空字符串,不含路径分隔符)。单例复用键强制含插件标识,多插件不串内存状态与锁。 除 pluginId 之外的配置都可选并带默认值,不传即走默认(见第 3 节)。

接线的第二个参数除 { ctx, logCtx } 外还能给 pluginManager(显式交宿主管理器实例)与 readerOverrides。 平时不用管;只有自动解析对不上目标包时(hoisted、多副本、开发态链接)才用得上它的 targetPackageDir:

createHostUpdate(
  { ctx, logCtx, readerOverrides: { targetPackageDir: '/abs/path/to/node_modules/my-notes-plugin' } },
  { pluginId: 'my-notes-plugin', prefix: 'notes', targetPackageName: 'my-notes-plugin' }
)

使用范围(profile)怎么定(`0.18.25` 起):本包按下面的顺序解析,前一步拿到就不再往后走——① 调用方显式传的 readerOverrides.profileDir / profileName;② DSH 注入的 ctx.profileContext(profile 启动的宿主在插件挂载前提供,含 name 与 dir);③ 第三方 Desktop 的 ctx.desktopProfiles.current({ name, dir });④ 调用方显式传入的 readerOverrides.env.DSH_PROFILE_DIR(名字取 DSH_PROFILE);⑤ 从已定位的目标包目录反推 /node_modules/。所以在 DSH 里运行(普通 profile 启动或 Desktop)不会因为目标包不在预期位置而认不出使用范围;只有完全没有 profile 上下文、也没显式覆盖、又反推不到的嵌入方才会拿到 unknown-profile(这时传 readerOverrides.profileDir / profileName)。识别出的名字会用于面板横幅与手工命令里的 --profile。本包不读隐式 process.env;要传环境快照请走 readerOverrides.env。

本包是 ESM("type": "module"):ESM 插件直接 import;CJS 插件用 await import('dsh-plugin-update')。

六个电话的入参与回参

面板与宿主两侧共用这一份契约(… 是你的前缀):

电话 · 入参 · 成功回包 · 失败回包 ….updateStatus · {} · { ok: true, snapshot, manual, receipt: null } ※ · { ok: false, error, errorKind, diag? } ….updateCheck · {} · { ok: true, snapshot, manual, receipt } ※ · 同上 ….updateInstall · { checkId, requestId, version?, approvedBuilds? } · { ok: true, snapshot, manual, receipt: null } ※ · 同上 ….updateChangelog · { version } · { ok: true, version, markdown: string \ · null, status, cached } ※ · 同上 ….logAppend · { rows: [{ level, event, fields, ts }] } · { accepted, dropped }(永不 ok:false、永不抛) · — ….updateCheckFallback · { kind, blame, targetVersion, windowMs, windowSource, publishedAtMs } · { ok: true, snapshot: { verdict, candidate }, receipt } ※ · 同上

※ 快照里带着失败任务时,成功回包另可带可选 diag(失败证据小对象,见下一条);无失败任务时形状逐字不变(0.18.7 起)。

※ updateChangelog 成功回包自 0.18.26 起另带 status 与 cached:status ∈ ok | empty | missing | fetch-failed | parse-degraded | cache-hit(这一版为什么有或没有说明——作者没写 / 包里没这个文件 / 取数失败 / 有内容但解析不出结构),cached: true 表示这次回的是宿主进程缓存。老集成方只读 markdown 不受影响;认不出的 status 值一律按老样子显示。命中缓存那一帧的 status 仍是那次取数的原始内容态(不会被改写成 cache-hit),cache-hit 只出现在日志行里。

  • ….updateInstall 的 version 可选:给了就装这个精确版(「装合规版」这条候选路径用),不给仍装快照里的最新版。
  • ….updateInstall 的 approvedBuilds 可选(0.18.23 起):宿主 pnpm 因「被忽略的构建脚本」失败时,失败详情会点名需要批准的精确包名(见 5.9);调用方决定批准后,把这些包名原样带回来重试一次,宿主先登记批准再跑 pnpm。重试必须换一个新的 `requestId`——同一个 requestId 重复提交会被当成重复请求直接返回旧结果(含旧的失败快照),不会真的重试;若宿主回 stale-approval(名单已过期/不在待批列表),说明批准窗口已过,应重新发起一次检查+安装、且不要带上次的 `approvedBuilds`。形状归一在宿主侧电话入口完成(非字符串、通配、开关样式、路径片段、包@版本 一律丢弃,去重保序、最多 32 条),认不出的项不会交给宿主;不传即现状,老调用方一字不动。
  • snapshot 恒为第 5.1 节那九个字段(七个核心 + identity + registered);manual 是第 5.3 节那条手工命令(能给则给,不能给为 null)。
  • receipt 查新版与合规版决议都给({ checkId, checkedAt, expiresAt });装更新时把 checkId 原样带回来,requestId 由面板自己生成(同一个编号重复提交直接返回旧结果)。
  • 失败一律 ok: false:error 是原因码(第 5.2 节那十一种,另加 check-failed / invalid-release / check-expired / update-busy / install-failed),errorKind 认不出时是 internal。面板按 17 码给中文文案(十一种见第 5.2 节,另五种与 internal 见下表,未来码走兜底);分支只认 errorKind,error 仅回退。
  • 失败可能顺带回可选 diag(失败证据小对象:阶段、路由、耗时、人话摘要、版本、宿主、请求与检查编号、源主机、动作提示;0.18.5 起另有归因三键 causeKind / causePackage / causeUnlockAt;#207 起再加一格 intentHash——请求编号的短指纹,拿它就能把这次失败的诊断回包、update.install.request 行与 update.install.fail 行对上;共 20 键;缺省即省略,序列化恒在 1KB 内)。旧面板直接忽略它,行为逐字不变;新面板也只按稳定码分支,不拿它做分支。
  • 0.18.7 起,快照里带着失败任务时成功回包也可能带这份 diag(批量面板与安装按钮靠它认出「该装合规版」);无失败任务时成功回包形状逐字不变。

第 3 步:面板侧接线(构建期派生)

目标只有一句:面板里不要写死电话名与轮询间隔。构建时用包内工具生成一个小文件:

node node_modules/dsh-plugin-update/derive-client-values.mjs --prefix notes --out scripts/generated/updateClient.derived.js

生成的常量直接在面板里用:

// UPD_STATUS / UPD_CHECK / UPD_INSTALL / UPD_POLL 由上面那条命令生成
host.call(UPD_STATUS, {})                        // 查状态
host.call(UPD_CHECK, {})                         // 查新版
host.call(UPD_INSTALL, { checkId, requestId })   // 装更新
setInterval(readStatus, UPD_POLL)                // 轮询

派生工具的自定义项:

  • --prefix 必填,必须与宿主侧 createHostUpdate 传的 prefix 一致,否则面板调的电话名与宿主注册的对不上。
  • --out 必填,故意不给默认值:默认值会覆盖别人的文件。
  • --dry-run 只打印生成内容、不写文件。
  • 工具用 esbuild 打包一次(构建期使用,不引入运行期依赖);找不到 esbuild 时打印安装提示,不静默失败。

以后换前缀或升级本包,重新跑一次这条命令即可。

第 4 步:面板侧挂载整组件(一个挂载点即跑)

前面三步是“自己拼面板”的走法;要整组件,把第 3 步的常量换成下面这一行(框架无关,任何面板直接嵌,样式隔离):

import { mountUpdatePanel } from 'dsh-plugin-update/panel'

// host.call 是你调宿主电话的函数:(phoneName, args) => Promise<reply>
const panel = mountUpdatePanel(document.getElementById('update-slot'), {
  pluginId: 'my-notes-plugin',
  prefix: 'notes',          // 与宿主侧一致;电话名从它算出,不写字面量
  mode: 'embedded',         // 默认内嵌;切弹窗传 'dialog',同一套内核
  showOthers: false,        // 默认只看自己的排队,他人仅露“正忙”占位
  call: (name, args) => host.call(name, args),
})
// 离开时 panel.unmount():只停轮询,安装在宿主侧继续跑;重开面板立刻重查,1 秒内恢复显示。
// 弹窗版想让「关闭」按钮真把弹窗撤掉:传 onCloseRequested(面板点关闭/Esc 时先调它撤 DOM,再停轮询;入口件打开的 dialog 已内置)。

自动检查(`autoCheck`,默认 `true`)。挂载后先只读一次本地状态(首刷),拿到快照后若判定需要就自动查一次新版(只读,不安装):面板还装着/验着、或已有动作在飞时不查(再查也只会得到同一结论);传 autoCheck: false 则挂载不自动查,只在用户点「检查更新」或你调 panel.refresh() 时动——关的只是自动检查,面板的只读轮询与用户手动检查照常(自查同样是只读,任何路径都不自动安装)。入口件与安装按钮的同名选项取值不同('mount' / 'never'),语义同向,见第 2.5、2.8 节。挂载日志行 update.panel.mount 的 autoCheck 一格记录这一面面板到底跑没跑:started(自动查已发起)/ recent(首刷已显示在忙——装着/验着或动作在飞,跳过)/ disabled(调用方关了自动检查)/ failed(首刷失败、没拿到状态,没跑成)——排查「面板为什么没有自己查」时看它;安装按钮那个选项只会落成 started / disabled。

组件内部消化的事(调用者不再写):按 panelPollMs 轮询查状态(下限 250 毫秒);安装按钮状态跟随快照的 canInstall,不另写门控规则;装不了的原因按第 5.2 节展示中文一句话;pending-restart 单独横幅加重启指引,不再给安装按钮;手工命令展示与复制;排队位置展示与 showOthers 开关;跳过按版本记(“已跳过 X.Y.Z · 恢复”在同一行,不藏进设置页);失败时旁边的“复制诊断”一键给出脱敏后的自包含文本(稳定码、版本、宿主、队列位置),深挖仍看日志(组件里留着第 6 节第 11 条的过滤口径)。失败若归因指向本插件、且最新版被 24 小时年龄门挡住,旁边另给「装合规版」与「等新版」两个按钮(0.18.6 起;先决议、点名版本确认后才装,名词与整套流程见第 2.6 节)。失败横幅常驻:查/装失败后保留到下一次用户主动查/装的新结论、任务态或版本变化,只读轮询不再洗掉;复制诊断锁定致命那次回包的证据(码、摘要、版本、宿主、编号冻结在失败时刻);失败旁有「知道了」可显式确认回到可装页(只清面板提示,不调电话)。无失败时复制诊断给当前状态快照(版本、宿主、范围、队列),不伪造失败码。04 章平时是路牌(中性行+日志过滤口径,可用 showLogHint: false 隐藏);失败时变为卷宗:真实稳定码、本次查询键(请求/检查编号+失败时刻)、证据冻结声明、凭编号对日志的现成查询。日志事件名(host.call/host.call.fail/update.install.exec)由宿主与面板共用单源,改名即全改。

待重启横幅上有个「重启宿主」入口。宿主没有“重启自己”的电话,所以默认点击只如实提示「请手动重启宿主」;想把重启流程接进来就传 onRestartRequested(面板调它,成败都回执):

mountUpdatePanel(slot, { pluginId: 'my-notes-plugin', prefix: 'notes', call: host.call,
  onRestartRequested: async () => { /* 拉起你自己的重启流程 */ } })

面板画成档案五章:01 检查与安装、02 更新日志、03 更新队列、04 错误信息、05 手工命令(五章恒在,缺内容给中性提示,不跳号)。档案头一行是「插件名 + 使用范围 + profile 牌」:使用范围由面板自动向宿主索取(电话入参 includeEnv,宿主只回范围名与宿主种类、不回任何路径),显示的就是更新要落到的那个 profile——web 与 desktop 各装一份,装错范围是严重故障,所以这一栏宁可显示“未知”也不猜。调用方知道得更准时可以用 profileName 显式覆盖。

可选档案卷主题(纸面浅色案卷风,不替换默认):挂载时加 theme: 'archive' 即换肤(右上大印章「待查/可装/安装中/待重启/受阻/已最新」+ 横幅小印章一字 + profile 牌 + 待重启衬线横幅配手绘 SVG 标 + 窄屏印章固定 + 省略号逐字折叠 + 浅深双主题跟随系统),内核 DOM 顺序不动、复制诊断常在;不传即最小可用默认深色样式。运行时用 panel.setTheme('archive' | 'default') 可切。入口件与批量面板是同一个 theme 参数,取值同一套。

主题变量覆盖(第三方换肤):四个 UI 口(单面板、批量面板、入口件、批量入口件)都收 themeTokens,键为语义名,一次传入即换肤,不传即零回归。入口件打开的 dialog 面板同步透传;运行时用 setThemeTokens({...}) 即时换肤,传 undefined 即清掉覆盖:

mountUpdatePanel(el, { pluginId: 'p', prefix: 'notes', call,
  theme: 'archive',
  themeTokens: { primary: '#c8402a', bg: '#fffdf6', radiusButton: '3px' },
})

键与样式表里的 --dsh-update-* 变量一一对应(括号里是变量名),手写 CSS 变量等价生效:

组 · 键(变量) 文字背景边线 · text、textMuted、bg、bgSoft、border、borderStrong、buttonBg 主色焦点 · primary、primaryDeep、focus 状态四组 · okBg / okBorder / okText、warnBg / warnBorder / warnText、badBg / badBorder / badText、busyBg / busyBorder / busyText 批量新版 · newText(未装好红)、newOkText(已装好绿) 字体阴影 · fontSans、fontSerif、fontMono、shadow 圆角 · radiusPanel、radiusButton、radiusBadge 入口件尺寸 · entryFontSize、entryPadding、entryBorderRadius、entryScale(数字,大于 0) 面板字号(#103) · panelFontSize(正文基准,默认 14px)、panelChapterFontSize(章节标题,默认 14px/档案卷 17px,批量标题 15px/档案卷 21px 同键)、panelBannerTitleFontSize(横幅标题,默认继承正文/档案卷 27px、已最新 24px)、panelLogFontSize(日志,默认 13px系)、panelSmallFontSize(小字副行/注脚,默认 11px–12.5px按规则回退)

三条约束:颜色只收 hex(#rgb/#rrggbb/#rrggbbaa)或英文名单词(含 transparent),rgb()/color-mix() 请自行换算成 hex;其余值走注入检查(分号引号括号等不许过,见 sizing 同口径);未知键与非法值挂载即抛错,不静默吞掉。被覆盖的 token 不再跟随深色模式(内联值固定,调用方自己保证深浅可读,字号类同理:传了即固定值,不跟媒体查询走)。

类型定义随包分发(dsh-plugin-update/panel 的 .d.ts),不用自编译;面板离线可读,与包版本绑定。

整组件 HTTP 版(无 host.call 环境即跑:浏览器面板经 HTTP POST 直达宿主网关,只传三样):

import { mountUpdatePanelHttp } from 'dsh-plugin-update/http'

mountUpdatePanelHttp(document.querySelector('#upd'), {
  pluginId: 'my-plugin',
  prefix: 'myplug',
  baseUrl: 'https://host.local:3000/upd',
  showOthers: false,
  pollMs: 1000,
})
// 离开时 panel.unmount():只停轮询,安装在宿主侧继续跑。

第 2.5 节:更新入口件(配置页上那一颗按钮)

目标只有一句:让用户不用点开就知道有没有事。 一行挂上:

import { mountUpdateEntry } from 'dsh-plugin-update/entry'

const entry = mountUpdateEntry(document.getElementById('upd-entry'), {
  pluginId: 'my-notes-plugin',
  prefix: 'notes',
  call: (name, args) => host.call(name, args),
})

三个自由度,默认值都选好了:

自由度 · 取值 · 默认 · 说明 摆什么 · button / badge / inline · button · 按钮;只给一个状态点;面板本体直接嵌进来 什么时候查 · mount / never · mount · 进页面静默查一次(只调 `.updateStatus`,只读);never 则只在点击时查 点了做什么 · has-update / always / manual / direct · has-update · 有新版本才开面板;检查完总是开;交给 onActivate 自己跳;点开即弹窗、不预查 无新版本摆哪 · button / tooltip / note · button · 版本即按钮本身;收进悬停;回右侧小字旧样子

按钮文案随状态自己变(按宿主语言单语渲染:中文界面纯中文、英文界面纯英文,一次只出现一种语言):检查更新(尚未查询)/ 已是最新 X.Y.Z(无新版本,默认即按钮本身)/ 有新版本 1.1.0 / 正在安装… / 待重启 / 更新失败,请查看详情(英文见集中字典同 key)。

语言跟随(单语):入口件默认跟随 document.documentElement.lang > navigator.languages > 中文兜底;显式覆盖传 locale: 'zh' | 'en' | { getActive(): string; subscribe?(cb): () => void }(对象形态可接宿主语言服务,切换即时重绘,unmount 后停订;无 DOM 环境不抛,落中文)。

无新版本时版本号摆哪(upToDateDisplay,缺省 'button'):button 即按钮本身就是已是最新 X.Y.Z(单按钮不占宽,可点再查,hover 回检查更新暗示动作;无新版本中性弱边,hover 才走主色);tooltip 按钮还是检查更新、版本收进悬停;note 回旧样子(按钮旁右侧小字,只在 openOn: 'has-update' 下点击确认无新版本后出现)。旧版默认即 note 的样子,要回去就显式传 upToDateDisplay: 'note'。想连按钮都不留痕,就用 openOn: 'always'(检查完总是开弹窗,无新版本在弹窗里看“已是最新”)或 openOn: 'direct'(点开即弹窗,连预查都省了,面板挂载即自查;徽标形态仍走回调口径)。

按钮尺寸(跟头行其他控件同高/整体缩放):传 sizing 只改按钮本体,不碰面板;不传即默认外观。scale 是整体缩放(默认 1,如 1.2 放大、0.9 缩小),原来写在容器上的 zoom 可整体搬进来:

mountUpdateEntry(el, { pluginId: 'p', prefix: 'notes', call,
  sizing: { fontSize: '12px', padding: '2px 8px', borderRadius: '8px', scale: 1 },
})

等价的手写 CSS 变量(与现有 --dsh-update-* 颜色变量同口径,写在容器或祖先元素上即可):--dsh-update-entry-font-size(默认 13px)、--dsh-update-entry-padding(默认 4px 12px)、--dsh-update-entry-border-radius(默认 6px,archive 主题下 3px)、--dsh-update-entry-scale(默认 1)。badge 圆点与 inline 内嵌不受影响。一点约束:sizing 与手写变量都不接 !important(变量本身已赢特异性);确需 !important 请手写 CSS 覆盖。

一条铁律:检查是只读、安装是写入,两者不许合并成一个动作。 入口件永远只做「查 + 打开面板」, 任何路径都不自动安装;用户必须在面板里明确点「安装」。想让点击交给自己(例如你已有自己的更新页):

mountUpdateEntry(el, { pluginId: 'p', prefix: 'notes', call, variant: 'badge',
  onActivate: ({ hasUpdate, latestVersion }) => { /* 自己跳自己的页面 */ } })

批量感知的入口件(一颗按钮看 N 家,点开即批量面板)

一个总管替 N 家管更新时,别再用单入口的 manual 桥接批量面板——徽标只反映总管自己一行,不是七家聚合。换这一颗:

import { mountUpdateBatchEntry } from 'dsh-plugin-update/entry-batch'

const entry = mountUpdateBatchEntry(document.getElementById('upd-entry'), {
  prefix: 'life',                              // 批量电话前缀(七个批量电话从它派生)
  call: (name, args) => host.call(name, args),
  theme: 'archive',                            // 与单入口/面板同一套皮肤
})
// 点击先调 `life.batchCheck` 查一次 N 家,再以 dialog 形态开批量面板;徽标即七家聚合。
// 下游删掉 `openOn: 'manual' + onActivate` 桥接即跑,总管单行不再当状态源。

聚合口径与批量面板总账同一份数法(含「忙失败占位翻回可更新」):忙 > 失败 > 待重启 > 可更新 > 待查, 一个收尾事实(0.18.7 起):账本目标没装成、但这家实际已装上「能装的最新」(合规版,见第 2.6 节)的行,按要不要重启进「待重启 / 已最新」,不再报安装失败。 文案按宿主语言单语渲染(中文如左,英文见字典 batch-entry.* 同 key),locale 选项与单入口同形态,切换即时重绘。 三自由度与单入口同取值,缺省不同:openOn 缺省 'always'(查完总是开批量面板;has-update 下有事才开,否则原地给总账一句)。 autoCheck 缺省 'mount'(只调 .batchStatus,只读);铁律同单入口:任何路径都不自动安装。

窗口期说明(必读):mountUpdatePanel 与 mountUpdateBatchPanel 接受 locale 选项(与入口件同形态);本包新增的文案(批量面板的合规版入口、安装按钮的兜底按钮等)已按它单语渲染,早期散落文案仍按基线中文渲染——按面迁移在后续版本继续落地,入口件两条链已全部生效。

第 2.6 节:多目标批量更新(一个插件管 N 个插件的更新)

一个插件替自己和另外几个插件管更新时,别把 N 个面板并排——用户在那块界面上只问三件事: 有没有事 / 是哪几家 / 我要做什么。所以这套东西是「总账 + 明细 + 动作」三层,一行只回答一个问题。

宿主侧:

import { createMultiHostUpdate } from 'dsh-plugin-update/batch'

const multi = createMultiHostUpdate({ ctx, logCtx }, {
  prefix: 'life',                       // 批量电话前缀
  selfKey: 'life-pack',                 // 「自己」:排序时排最后(自更新安全)
  targets: [
    { key: 'bill',    title: '记账',   packageName: 'dsh-bill-ilife',    prefix: 'ilife-bill' },
    { key: 'calorie', title: '卡路里', packageName: 'dsh-calorie',       prefix: 'ilife-calorie' },
    { key: 'life-pack', title: '爱生活', packageName: 'dsh-life-pack',   prefix: 'ilife-life-pack' },
  ],
  // drain: true,                       // 宿主侧定时推进(默认关:自动装是行为跃迁,显式开)
})
for (const [name, handler] of Object.entries(multi.handlers)) registry.set(name, handler)

七个批量电话(` 即上面的 life,同时是账本属主身份):batchStatus / batchCheck / batchInstall / batchResume / batchCancel / batchPrefs / batchPrefsSave; 每个目标的六个单插件电话照旧以**各自前缀**暴露(ilife-bill.updateStatus、ilife-bill.updateCheckFallback 等);每行回包里的 phoneNames 带全这六个名字,合规版兜底与候选安装都按它点名(0.18.7` 起)。

回包形状(成功六项,失败只有三项):

{ ok: true, session, rows, progress, inventory, prefs }  // rows 一行一家:key/title/phase/targetVersion/restartRequired/error/snapshot/phoneNames(本行那家的六个电话名)、可选 diag 与 pluginId;#177 起可选 manualSource(frame / carried-over / null)
{ ok: false, error, errorKind }            // 跨使用范围混目标会回 cross-scope,不抢锁、不写盘

batchResume 无事可续时回 resumed: false(面板不再报空话成功);batchPrefsSave 只收布尔 checkOnOpen。