dsh-m 0.9.12 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -36,7 +36,7 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
36
36
 
37
37
  | View | Capabilities |
38
38
  |---|---|
39
- | **Market** | **Zoned** (0.7.0): **Community** (default landing, the 4,000+ full catalog) and **Curated** (hand-picked primary registry, single page) as two independent zones — each keeps its own category/search/sort/pagination state across tab switches; plus a **Favorites** zone (browser-local bookmarks with delisted-entry detection and one-click cleanup). Community zone: two-row collapsible category chips with sticky collapse, sort switch (downloads/stars/added × asc/desc), windowed pagination (24/48/96 per page), card byline (owner/downloads/stars); clicking a card opens a **detail modal** (superset of the card: download window, screenshot lightbox, capabilities collapsed by default, install command); npm sources pin the latest exact version, GitHub sources pin the release/tag commit |
39
+ | **Market** | **Zoned** (0.7.0): **Community** (default landing, the 4,000+ full catalog) and **Curated** (hand-picked primary registry, single page) as two independent zones — each keeps its own category/search/sort/pagination state across tab switches; plus a **Favorites** zone (browser-local bookmarks with delisted-entry detection and one-click cleanup). Community zone: two-row collapsible category chips with sticky collapse, sort switch (downloads/stars/added × asc/desc), windowed pagination (24/48/96 per page), card byline (owner/downloads/stars); clicking a card opens a **detail modal** (superset of the card: download window, screenshot lightbox, capabilities collapsed by default, install command — the row is hidden on desktop and for installed entries since 0.9.14); npm sources pin the latest exact version, GitHub sources pin the release/tag commit |
40
40
  | **Operations** | Install/upgrade/uninstall/toggle all flow through a global operation log (0.7.0): state lives off cards, surviving paging/searching/tab switches; persisted to localStorage and resumed after host reloads (each restored op re-validated "still applies" before executing); benignly-invalidated ops show as a neutral "skipped"; the Installed page gains "Update all (N)" batch queueing |
41
41
  | **Installed** | What the current profile (web / desktop, 0.9.0+) actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap); **live phase badge** (●active / ●failed / ○pending) and a **one-click toggle** (0.4.0: delegates to the official pluginManager service for live application; falls back to file-level edits + restart notice when absent; dsh-m itself and official host lifelines are locked) |
42
42
  | **Settings** | Registry address draft with "Force refresh / Validate & apply / Restore default / Download default registry.json"; configured vs active address and status at a glance (shown only when abnormal); community catalog toggle; dsh-m self-update |
@@ -48,6 +48,21 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
48
48
 
49
49
  The restart chain below applies to **Web only** (Desktop takes effect via the official app lifecycle: after installing, quit and reopen the Desktop app — see the 0.9.0 section). After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The restart chain has been live-verified on the current DSH Web `0.2.0-rc.2` (2026-10-01): dsh-m probes the unit's `Restart=` policy, hands the stop back via `appExit`, the `status=75/TEMPFAIL` exit is caught by the Restart policy and auto-started, and after recovery the page reconnects in the background with the panel fully functional. Historical scope: the `0.1.5-rc.1` era covered live `/dshm` ping and authenticated `303 → 200` checks; `0.1.2-rc.1` is covered by contract checks plus runtime shape-detection fallback (see the verified array) — with the deployment moved on, no live E2E is chased on retired generations; the transient `systemd-run` fallback only applies to hosts without `appExit` (all supported generations provide it) and stays a documented fallback rather than a release gate; repeated install/uninstall is covered by the profile-transaction test surface (compensation, post-install guard, 813 cases) plus the 0.4.0→0.4.2 consecutive-release evidence. Installs stream live pnpm progress (resolve → download → link → build).
50
50
 
51
+ ### Changed in 0.9.14 (instant-open quartet + context-aware "Install command" row)
52
+
53
+ - **Instant-open quartet** — a fix combo for the marketplace spinner that used to show on every panel open:
54
+ - **SWR (serve-stale-while-revalidate)**: expired TTL no longer blocks on the network — the disk cache is returned immediately as a snapshot (the community zone keeps its honest "cached snapshot" banner; never pretending to be fresh), a background single-flight refreshes it for the next open. "Force refresh" semantics unchanged (always synchronous; community card stays decoupled).
55
+ - **Route stickiness**: the default two-route chain (GitHub raw → jsDelivr mirror) is ordered by the **last successful route** recorded in the cache — once the mirror has worked it goes first, instead of re-paying the raw timeout on every cold open (~10-20s saved per cold open on CN networks).
56
+ - **Persistent probe cache**: per-page npm/GitHub latest-version probes now persist to a disk envelope (`latest/`, survives restarts) — no probe replay on the first open after a DSH service restart.
57
+ - **Client snapshot**: the default first page is cached browser-locally (10-min TTL); opening the panel renders the last response first and refreshes in the background — the spinner now only appears on the very first use (no snapshot yet).
58
+ - **Detail modal "Install command" row is context-aware**:
59
+ - **Scenario**: the row's derived command hardcodes `dsh plugin --profile web add …`, and community entries' upstream `install` text carries the same web-profile semantics — neither source looks at the current host profile. Copying it on Desktop installs into the web profile (invisible in the current UI); for already-installed entries the command is pure noise (live screenshot evidence: desktop + the dsh-m self entry, `installed` badge and the command on screen together).
60
+ - **Change**: the row is hidden entirely on desktop and for installed entries; web + not-installed keeps today's behavior (the CLI bootstrap path, same command as Quick start). The sanctioned Desktop path is the modal's own Install button (official pluginManager delegation, ADR 0005 discipline). No "simplify by dropping --profile": the explicit `--profile web` flag is a settled 0.9.0 decision (see the "profile target" section), and a bare `dsh plugin add` has ambiguous default-profile semantics — not simpler, just worse.
61
+
62
+ ### Changed in 0.9.13 — desktop chip hover copy updated (owner decision)
63
+
64
+ - The old copy "当前 DSH profile:{name}(Desktop 首发仅支持只读市场、安装新包与开关)" was stale after 0.9.8 opened upgrades/uninstall/self-upgrade; per owner decision it now reads "当前生效 Profile" (Active profile).
65
+
51
66
  ### Changed in 0.9.12 — re-issue of 0.9.11
52
67
 
53
68
  0.9.11 hit a npmjs ghost publish: the OIDC publish was accepted and **staged** (CLI exit 0, provenance published to the transparency log) but never committed into the registry — GET 404, and re-publishing the same version is rejected with 409 `Cannot publish over previously staged version`. After waiting out any propagation, the standard workaround applies: re-issue under a new number. **Content is identical to 0.9.11: settings force-refresh no longer takes down the community catalog card.**
package/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  | 视图 | 能力 |
38
38
  |---|---|
39
- | **市场** | **分区制**(0.7.0):**社区**(默认落地页,4,000+ 条全量目录)与**精选**(手工策展主清单,单页直出)两个独立分区,各有自己的分类/搜索/排序/分页状态(切 tab 互不重置);另有**收藏**分区(浏览器本地书签,自动检测下架条目并一键清理)。社区区:分类 chips 两行折叠 + 吸顶收缩、排序(下载量/Star/收录日期 × 升降)、页码窗口化分页(24/48/96 每页)、卡片 byline(作者/下载量/Star);卡片点开**详情 Modal**(卡片信息超集:下载量窗口三要素、截图灯箱、能力披露默认收起、安装命令);npm 源锁定最新精确版本,GitHub 源锁定 release/tag 指向的 commit |
39
+ | **市场** | **分区制**(0.7.0):**社区**(默认落地页,4,000+ 条全量目录)与**精选**(手工策展主清单,单页直出)两个独立分区,各有自己的分类/搜索/排序/分页状态(切 tab 互不重置);另有**收藏**分区(浏览器本地书签,自动检测下架条目并一键清理)。社区区:分类 chips 两行折叠 + 吸顶收缩、排序(下载量/Star/收录日期 × 升降)、页码窗口化分页(24/48/96 每页)、卡片 byline(作者/下载量/Star);卡片点开**详情 Modal**(卡片信息超集:下载量窗口三要素、截图灯箱、能力披露默认收起、安装命令(0.9.14 起 desktop 上下文/已安装条目隐藏该折叠行));npm 源锁定最新精确版本,GitHub 源锁定 release/tag 指向的 commit |
40
40
  | **操作记录** | 安装/升级/卸载/开关全部走全局操作记录(0.7.0):状态不挂卡片,翻页/搜索/切 tab 不丢;localStorage 持久化,宿主重载后自动恢复未完成操作(逐条校验「此刻仍成立才执行」);良性前提消失以中性「已跳过」呈现;已装页支持「全部更新 (N)」批量入队 |
41
41
  | **已装** | 当前 profile(web / desktop,0.9.0 起)实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断);**运行相位徽标**(●active / ●failed / ○pending)与**一键开关**(0.4.0:委派官方 pluginManager 服务活体生效,服务缺席时文件级编辑 + 重启提示;dsh-m 自身与官方宿主命脉锁定不可开关) |
42
42
  | **设置** | registry 地址草稿 +「强制刷新 / 校验并应用 / 恢复默认 / 下载默认 registry.json」;配置地址、生效来源与状态一目了然(仅异常时提示);社区目录开关;dsh-m 自更新 |
@@ -48,6 +48,21 @@
48
48
 
49
49
  以下重启链路仅适用于 **Web**(Desktop 的生效走官方应用生命周期:安装完成后退出并重新打开 Desktop 应用即可,见 0.9.0 节)。安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。重启链路已在当前 DSH Web `0.2.0-rc.2` 实机核验(2026-10-01):dsh-m 探测 unit `Restart=` 策略后经 `appExit` 交还 systemd,`status=75/TEMPFAIL` 退出由 Restart 策略接住自动拉起,服务恢复后页面后台重连、面板全功能可用。历史口径:`0.1.5-rc.1` 时代已核验 `/dshm` ping 与带认证 `303 → 200`;`0.1.2-rc.1` 按契约核对 + 形态探测兜底收录(verified 数组),部署面演进后不再追旧代 live E2E;transient `systemd-run` 兜底仅适用于无 `appExit` 的宿主(受支持代际均提供 `appExit`),保持设计兜底而非发布门槛;连续安装/卸载由 profile 事务测试面(补偿事务、装后守卫等 813 用例)与 0.4.0→0.4.2 连续发布实证覆盖。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
50
50
 
51
+ ### 0.9.14 变更(市场秒开四件套 + 详情 Modal 安装命令显隐)
52
+
53
+ - **市场秒开四件套**:针对「每次打开市场必现『加载收录清单中…』」的加速组合拳——
54
+ - **SWR 先回缓存**:精选清单与社区目录的 TTL 过期不再同步等网络——磁盘缓存存在即**立即返回快照**(社区侧照常显示「缓存快照」横幅,绝不冒充最新),后台单飞自愈,下次打开即新;「强制刷新」按钮语义不变(始终同步强刷,社区卡不连坐)。
55
+ - **线路粘性**:默认双线路(GitHub 原始文件 → jsDelivr 镜像)按缓存记录的**上次成功线路**排序——镜像成功过就先走镜像,不再每次白等主线路失败(大陆网络实测每次冷打开省 10-20s 等待)。
56
+ - **探测缓存落盘**:页条目 npm/GitHub 版本探测缓存从纯内存改为磁盘信封(`latest/`,跨重启存活)——DSH 服务重启后首次打开市场不再重放探测。
57
+ - **客户端快照**:默认首页响应存浏览器本地(10 分钟 TTL),打开面板先渲染上次数据再后台换新——加载 spinner 仅首次使用(无任何快照)出现。
58
+ - **详情 Modal「安装命令」折叠行按上下文显隐**:
59
+ - **场景**:折叠行命令推导写死 `dsh plugin --profile web add …`,社区条目的上游 install 原文也同为 --profile web 语义——两个来源都不看当前宿主 profile。desktop 上下文里照抄会把包装进 web profile(当前界面看不见);已安装条目还挂着命令纯属噪音(实机截图实证:desktop + dsh-m 自身条目,`已安装` 徽章与命令同屏)。
60
+ - **变更**:desktop 上下文与已安装条目**整行隐藏**;web 未安装条目行为不变(CLI bootstrap 路径保留,「30 秒上手」同款命令)。desktop 装机正路是弹窗内「安装」按钮(官方 pluginManager 委派,ADR 0005 纪律)。不做「精简命令去掉 --profile」——显式 `--profile web` 是 0.9.0 拍板的设计(README「profile 目标」节),裸命令的默认 profile 语义含糊,精简反而更差。
61
+
62
+ ### 0.9.13 变更:desktop 角标悬停文案更新(主人拍板)
63
+
64
+ - 旧文案「当前 DSH profile:{name}(Desktop 首发仅支持只读市场、安装新包与开关)」在 0.9.8 开放升级/卸载/自升级后已过时;按主人拍板改为「**当前生效 Profile**」(en: Active profile)。
65
+
51
66
  ### 0.9.12 变更:0.9.11 的重发
52
67
 
53
68
  0.9.11 在 npmjs 遭遇幽灵发布:OIDC 发布被受理并 **staged**(CLI exit 0、provenance 已上 Transparency Log),但从未 commit 进注册表——GET 404、同版本重发 409 `Cannot publish over previously staged version`。等待自愈无果后按标准解法换号重发。**内容与 0.9.11 完全一致:设置页「强制刷新」不再连坐社区清单卡。**
package/docs/DESIGN.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
22
22
  - **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
23
23
  - **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
24
+ - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约与「缓存快照」横幅),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存整体迁入 `latest-cache.ts`:内存 Map + `<cacheRoot>/latest/<ns>.json` 信封 write-through(`latest/` 在 nsDir 之外,规避 `pruneCaches` 顶层 `*.json` 清扫;懒 seed、损坏/版本不符整弃、24h 硬上限淘汰),跨服务重启存活。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
24
25
  - **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
25
26
 
26
27
  ### 2.2 Schema(v1 定稿,严格校验)
@@ -88,7 +89,7 @@
88
89
  - 分页:页码窗口化(`1 … n-1 n n+1 … total`,≤7 页全显)+ 页大小 24/48/96 + 筛选变化重置页 1 + 翻页回顶;仅 prev/next 的旧分页退役。
89
90
  - 搜索:相关性加权管线——NFKC 归一化 + 中西文边界插空格 + 字段权重(name/npm > owner > 描述 > 分类)+ 命中类型加分 + 按条目缓存归一化结果;输入 250ms debounce + IME composition 全程处理 + draft/已提交 query 分离。
90
91
  - 描述 5 行钳制(真实溢出才显示展开钮);**数据层双语**——描述按 UI 语言取 zh/en(数据现成),UI 完整双语字典列 backlog。
91
- - **详情 = Modal 且为卡片超集**(「detail 显示少于摘要就是倒退」):byline / 分类 / 收录日期 / 下载量窗口三要素(计数 + 窗口 + 核对时间)/ 描述全文 / 截图灯箱(←→/Esc、**禁自动轮播**)/ 能力披露 + 红线(**默认收起**)/ 安装命令折叠行 / deprecated 替代链接;精选条目超集另加 verified 与 tags。**安装确认走 Modal**。卡片瘦身:能力披露、截图、安装命令全部迁出卡片(Q44 精神「只在详情层、默认收起」不变,载体升级)。
92
+ - **详情 = Modal 且为卡片超集**(「detail 显示少于摘要就是倒退」):byline / 分类 / 收录日期 / 下载量窗口三要素(计数 + 窗口 + 核对时间)/ 描述全文 / 截图灯箱(←→/Esc、**禁自动轮播**)/ 能力披露 + 红线(**默认收起**)/ 安装命令折叠行(0.9.14 起按上下文显隐:desktop 上下文与已安装条目整行隐藏——命令两来源恒为 `--profile web` 语义,desktop 装机走弹窗内安装按钮)/ deprecated 替代链接;精选条目超集另加 verified 与 tags。**安装确认走 Modal**。卡片瘦身:能力披露、截图、安装命令全部迁出卡片(Q44 精神「只在详情层、默认收起」不变,载体升级)。
92
93
  - **全局操作记录**:每个变更操作一条 record(`queued / running / input=冲突待决 / done / warned / failed`),**状态不挂卡片**——翻页、搜索、切 tab 不丢;**localStorage 持久化队列**,宿主重载恢复时逐条校验「此刻仍成立才执行,否则报告」;覆盖安装、升级、卸载、开关。已装侧跟随一致化:「全部更新 (N)」批量入口 + tab 更新红点 + 卡片视觉与发现侧同体系;组管理、个人备注列 backlog。
93
94
  - **收藏**:浏览器 localStorage 本地收藏(不进 profile、不进服务端);收藏区 stale 条目(目录中已下架)单独提示 + 一键清理。
94
95
  - **agent/CLI 契约(允许破坏性变更)**:`dshm_search` 参数改为 `query / category / source('primary'|'community'|'all',默认 all,与 GUI 分区对齐) / limit(默认 10,clamp 1–80) / offset(真翻页)`;`primary_only` 删除。输出补 `community` 标记与 `downloads/stars`;社区分类直出中文标签(不再回退英文 slug);工具描述整体重写(真实规模 + 翻页语义,删「registry is curated & small」)。CLI 对齐 `--source / --offset / --limit`,默认 10 条。
package/lib/client.js CHANGED
@@ -237,6 +237,98 @@ var init_market_state = __esm({
237
237
  }
238
238
  });
239
239
 
240
+ // src/client/market-snapshot.js
241
+ var market_snapshot_exports = {};
242
+ __export(market_snapshot_exports, {
243
+ MARKET_SNAPSHOT_TTL_MS: () => MARKET_SNAPSHOT_TTL_MS,
244
+ clearMarketSnapshots: () => clearMarketSnapshots,
245
+ isDefaultFirstPageQuery: () => isDefaultFirstPageQuery,
246
+ readMarketSnapshot: () => readMarketSnapshot,
247
+ snapshotKey: () => snapshotKey,
248
+ writeMarketSnapshot: () => writeMarketSnapshot
249
+ });
250
+ function snapshotKey(zone) {
251
+ return `dshm-marketsnap-${zone === "primary" ? "primary" : "community"}`;
252
+ }
253
+ function defaultLimitOf(zone) {
254
+ return zone === "primary" ? 96 : DEFAULT_PAGE_SIZE;
255
+ }
256
+ function defaultSortOf(zone) {
257
+ return zone === "community" ? { field: "downloads", dir: "desc" } : null;
258
+ }
259
+ function sameJson(a, b) {
260
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
261
+ }
262
+ function isDefaultFirstPageQuery(query, zone) {
263
+ const q = query && typeof query === "object" ? query : {};
264
+ if (typeof q.query === "string" ? q.query !== "" : q.query != null) return false;
265
+ if (q.category != null && q.category !== "") return false;
266
+ const offset = typeof q.offset === "number" && Number.isFinite(q.offset) ? q.offset : 0;
267
+ if (offset !== 0) return false;
268
+ if ((q.limit ?? DEFAULT_PAGE_SIZE) !== defaultLimitOf(zone)) return false;
269
+ return sameJson(q.sort ?? null, defaultSortOf(zone));
270
+ }
271
+ function readMarketSnapshot(storage, opts = {}) {
272
+ if (!storage || typeof storage.getItem !== "function") return null;
273
+ const zone = opts.zone === "primary" ? "primary" : "community";
274
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
275
+ const ttlMs = typeof opts.ttlMs === "number" ? opts.ttlMs : MARKET_SNAPSHOT_TTL_MS;
276
+ let raw = null;
277
+ try {
278
+ raw = storage.getItem(snapshotKey(zone));
279
+ } catch {
280
+ return null;
281
+ }
282
+ if (typeof raw !== "string" || raw === "") return null;
283
+ let box;
284
+ try {
285
+ box = JSON.parse(raw);
286
+ } catch {
287
+ return null;
288
+ }
289
+ if (!box || typeof box !== "object") return null;
290
+ if (typeof box.ts !== "number" || !Number.isFinite(box.ts)) return null;
291
+ if (now - box.ts > ttlMs) return null;
292
+ const response = box.response;
293
+ if (!isDefaultFirstPageResponse(response, zone)) return null;
294
+ return response;
295
+ }
296
+ function writeMarketSnapshot(storage, opts = {}) {
297
+ if (!storage || typeof storage.setItem !== "function") return;
298
+ const zone = opts.zone === "primary" ? "primary" : "community";
299
+ const response = opts.response;
300
+ if (!isDefaultFirstPageResponse(response, zone)) return;
301
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
302
+ try {
303
+ storage.setItem(snapshotKey(zone), JSON.stringify({ ts: now, response }));
304
+ } catch {
305
+ }
306
+ }
307
+ function clearMarketSnapshots(storage) {
308
+ try {
309
+ if (!storage || typeof storage.removeItem !== "function") return;
310
+ storage.removeItem(snapshotKey("community"));
311
+ storage.removeItem(snapshotKey("primary"));
312
+ } catch {
313
+ }
314
+ }
315
+ function isDefaultFirstPageResponse(response, zone) {
316
+ if (!response || typeof response !== "object" || Array.isArray(response)) return false;
317
+ if (!Array.isArray(response.items)) return false;
318
+ if (typeof response.total !== "number" || !Number.isFinite(response.total)) return false;
319
+ const offset = typeof response.offset === "number" && Number.isFinite(response.offset) ? response.offset : 0;
320
+ if (offset !== 0) return false;
321
+ return (response.limit ?? DEFAULT_PAGE_SIZE) === defaultLimitOf(zone);
322
+ }
323
+ var MARKET_SNAPSHOT_TTL_MS;
324
+ var init_market_snapshot = __esm({
325
+ "src/client/market-snapshot.js"() {
326
+ "use strict";
327
+ init_market_state();
328
+ MARKET_SNAPSHOT_TTL_MS = 10 * 60 * 1e3;
329
+ }
330
+ });
331
+
240
332
  // src/client/markdown.js
241
333
  var markdown_exports = {};
242
334
  __export(markdown_exports, {
@@ -1034,6 +1126,22 @@ var init_self_check = __esm({
1034
1126
  }
1035
1127
  });
1036
1128
 
1129
+ // src/client/install-cmd.js
1130
+ var install_cmd_exports = {};
1131
+ __export(install_cmd_exports, {
1132
+ shouldShowInstallCmd: () => shouldShowInstallCmd
1133
+ });
1134
+ function shouldShowInstallCmd(profileKind, installed) {
1135
+ if (profileKind === "desktop") return false;
1136
+ if (installed === true) return false;
1137
+ return true;
1138
+ }
1139
+ var init_install_cmd = __esm({
1140
+ "src/client/install-cmd.js"() {
1141
+ "use strict";
1142
+ }
1143
+ });
1144
+
1037
1145
  // src/client/main.jsx
1038
1146
  var React = require("react");
1039
1147
  var rd = require("react-dom");
@@ -1042,6 +1150,7 @@ var { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = Rea
1042
1150
  var PLUGIN_ID = "dsh-m";
1043
1151
  var API = "/dshm";
1044
1152
  var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2 } = (init_market_state(), __toCommonJS(market_state_exports));
1153
+ var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1045
1154
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1046
1155
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
1047
1156
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
@@ -1052,6 +1161,7 @@ var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __to
1052
1161
  var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2 } = (init_operations(), __toCommonJS(operations_exports));
1053
1162
  var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
1054
1163
  var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
1164
+ var { shouldShowInstallCmd: shouldShowInstallCmd2 } = (init_install_cmd(), __toCommonJS(install_cmd_exports));
1055
1165
  var ZH = {
1056
1166
  "market.title": "\u63D2\u4EF6\u5E02\u573A",
1057
1167
  "tab.market": "\u5E02\u573A",
@@ -1293,7 +1403,7 @@ var ZH = {
1293
1403
  "restart.timeout": "\u91CD\u542F\u8D85\u65F6\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 dsh web \u670D\u52A1\u72B6\u6001",
1294
1404
  "restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u6062\u590D\u540E DSH Web \u4F1A\u5728\u540E\u53F0\u81EA\u52A8\u91CD\u8FDE\u3002",
1295
1405
  "profile.restartHint": "\u53D8\u66F4\u5B8C\u6210\u3002Desktop \u63D2\u4EF6\u7531\u5B98\u65B9\u5E94\u7528\u7BA1\u7406\uFF1A\u8BF7\u9000\u51FA\u5E76\u91CD\u65B0\u6253\u5F00 Desktop \u5E94\u7528\uFF08\u5173\u95ED\u7A97\u53E3\u53EF\u80FD\u53EA\u662F\u9690\u85CF\uFF09\u4EE5\u52A0\u8F7D\u65B0\u72B6\u6001\u3002",
1296
- "profile.chipTitle": "\u5F53\u524D DSH profile\uFF1A{name}\uFF08Desktop \u9996\u53D1\u4EC5\u652F\u6301\u53EA\u8BFB\u5E02\u573A\u3001\u5B89\u88C5\u65B0\u5305\u4E0E\u5F00\u5173\uFF09",
1406
+ "profile.chipTitle": "\u5F53\u524D\u751F\u6548 Profile",
1297
1407
  "selfupdate.available": "\u53D1\u73B0\u65B0\u7248\u672C v{v}\uFF0C\u70B9\u51FB\u5347\u7EA7",
1298
1408
  "selfupdate.ahead": "\u672C\u5730\u5F00\u53D1\u7248\uFF08\u9886\u5148 npm\uFF1Av{v}\uFF09",
1299
1409
  "selfupdate.checking": "\u68C0\u67E5\u66F4\u65B0\u4E2D\u2026",
@@ -1554,7 +1664,7 @@ var EN = {
1554
1664
  "restart.timeout": "Restart timed out \u2014 check the dsh web service manually",
1555
1665
  "restart.hint.done": "Restart requested (via {via}). DSH Web will reconnect in the background after the service returns.",
1556
1666
  "profile.restartHint": "Changes applied. Desktop plugins are managed by the official app: quit and reopen the Desktop app (closing the window may only hide it) to load the new state.",
1557
- "profile.chipTitle": "Current DSH profile: {name} (Desktop first release supports read-only market, installing new packages, and toggles)",
1667
+ "profile.chipTitle": "Active profile",
1558
1668
  "selfupdate.available": "New version v{v} available \u2014 click to upgrade",
1559
1669
  "selfupdate.ahead": "Local dev build (ahead of npm: v{v})",
1560
1670
  "selfupdate.checking": "Checking for updates\u2026",
@@ -1873,19 +1983,27 @@ function useAsync(fn, deps) {
1873
1983
  }
1874
1984
  function useMarketData(zone = "community") {
1875
1985
  const [query, setQuery] = useState(() => normalizeMarketQuery2(createZoneState2(zone), zone));
1876
- const [data, setData] = useState(null);
1877
- const [loading, setLoading] = useState(true);
1986
+ const [boot] = useState(() => {
1987
+ const snap = readMarketSnapshot2(typeof window !== "undefined" && window.localStorage ? window.localStorage : null, { zone });
1988
+ return snap ? { data: normalizeMarketResponse2(snap), has: true } : { data: null, has: false };
1989
+ });
1990
+ const [data, setData] = useState(boot.data);
1991
+ const [loading, setLoading] = useState(!boot.has);
1878
1992
  const [error, setError] = useState(null);
1879
1993
  const genRef = useRef(0);
1880
1994
  const abortRef = useRef(null);
1881
1995
  const queryRef = useRef(query);
1882
- const fetchPage = useCallback((nextQuery, force) => {
1996
+ const dataRef = useRef(boot.data);
1997
+ const storage = () => typeof window !== "undefined" && window.localStorage ? window.localStorage : null;
1998
+ const fetchPage = useCallback((nextQuery, force, background = false) => {
1883
1999
  const gen = ++genRef.current;
1884
2000
  abortRef.current?.abort();
1885
2001
  const ac = new AbortController();
1886
2002
  abortRef.current = ac;
1887
- setLoading(true);
1888
- setError(null);
2003
+ if (!background || !dataRef.current) {
2004
+ setLoading(true);
2005
+ setError(null);
2006
+ }
1889
2007
  const params = {
1890
2008
  query: nextQuery.query || void 0,
1891
2009
  category: nextQuery.category || void 0,
@@ -1897,10 +2015,14 @@ function useMarketData(zone = "community") {
1897
2015
  };
1898
2016
  return api("market", params, ac.signal).then((raw) => {
1899
2017
  if (genRef.current !== gen || ac.signal.aborted) return;
1900
- setData(normalizeMarketResponse2(raw));
2018
+ const next = normalizeMarketResponse2(raw);
2019
+ dataRef.current = next;
2020
+ setData(next);
1901
2021
  setLoading(false);
2022
+ if (isDefaultFirstPageQuery2(nextQuery, zone)) writeMarketSnapshot2(storage(), { zone, response: next });
1902
2023
  }).catch((e) => {
1903
2024
  if (genRef.current !== gen || ac.signal.aborted) return;
2025
+ if (background && dataRef.current) return;
1904
2026
  setError(String(e && e.message || e));
1905
2027
  setLoading(false);
1906
2028
  });
@@ -1913,7 +2035,7 @@ function useMarketData(zone = "community") {
1913
2035
  }, [fetchPage]);
1914
2036
  const reload = useCallback((force) => fetchPage(queryRef.current, force), [fetchPage]);
1915
2037
  useEffect(() => {
1916
- fetchPage(queryRef.current, false);
2038
+ fetchPage(queryRef.current, false, boot.has);
1917
2039
  return () => abortRef.current?.abort();
1918
2040
  }, [fetchPage]);
1919
2041
  return { query, data, loading, error, reload, updateQuery };
@@ -2266,7 +2388,7 @@ function useModalDepth(active) {
2266
2388
  };
2267
2389
  }, [active]);
2268
2390
  }
2269
- function DetailModal({ it, labels, busy, onClose, onInstall }) {
2391
+ function DetailModal({ it, labels, busy, onClose, onInstall, profileKind }) {
2270
2392
  useModalDepth(true);
2271
2393
  const shots = it.community === true ? safeScreenshots(it) : [];
2272
2394
  const [lb, setLb] = useState(null);
@@ -2296,6 +2418,7 @@ function DetailModal({ it, labels, busy, onClose, onInstall }) {
2296
2418
  }
2297
2419
  };
2298
2420
  const descFull = browserLang() === "en" && typeof it.descriptionEn === "string" && it.descriptionEn !== "" ? it.descriptionEn : it.description;
2421
+ const showInstallCmd = shouldShowInstallCmd2(profileKind, it.installed === true);
2299
2422
  const kv = (k, v) => h(React.Fragment, { key: k }, h("dt", null, k), h("dd", null, v));
2300
2423
  return h(
2301
2424
  "div",
@@ -2356,7 +2479,7 @@ function DetailModal({ it, labels, busy, onClose, onInstall }) {
2356
2479
  Array.isArray(it.capabilityRedLines) && it.capabilityRedLines.length ? h("div", { className: "dshm-err" }, `${lookup("detail.redlines")}: ${it.capabilityRedLines.join("; ")}`) : null
2357
2480
  ) : h("div", { className: "dshm-hint" }, lookup("detail.capabilities.unscanned"))
2358
2481
  ) : null,
2359
- h(
2482
+ showInstallCmd ? h(
2360
2483
  "details",
2361
2484
  { className: "dsvm-fold" },
2362
2485
  h("summary", null, lookup("modal.installcmd")),
@@ -2366,7 +2489,7 @@ function DetailModal({ it, labels, busy, onClose, onInstall }) {
2366
2489
  h("code", { className: "dsvm-code" }, installCmd),
2367
2490
  h("button", { className: "dshm-btn sm", onClick: copyCmd }, copied ? lookup("modal.copied") : lookup("modal.copy"))
2368
2491
  )
2369
- ),
2492
+ ) : null,
2370
2493
  h(
2371
2494
  "div",
2372
2495
  { className: "dsvm-modalactions" },
@@ -2381,7 +2504,7 @@ var ZONE_TABS = [
2381
2504
  { id: "primary", labelKey: "zone.primary" },
2382
2505
  { id: "favorites", labelKey: "zone.favorites" }
2383
2506
  ];
2384
- function MarketTab({ notify, markets, onMutation, ops, favorites }) {
2507
+ function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind }) {
2385
2508
  const [zone, setZone] = useState("community");
2386
2509
  const market = zone === "favorites" ? null : markets[zone];
2387
2510
  const { data, loading, error, reload, query, updateQuery } = market || {};
@@ -2559,7 +2682,8 @@ function MarketTab({ notify, markets, onMutation, ops, favorites }) {
2559
2682
  labels: communityLabels(markets.community.data),
2560
2683
  busy: activeInstallTarget === favDetailItem.id,
2561
2684
  onClose: () => setFavDetailItem(null),
2562
- onInstall: (it2) => doInstall(it2)
2685
+ onInstall: (it2) => doInstall(it2),
2686
+ profileKind
2563
2687
  }) : null
2564
2688
  );
2565
2689
  }
@@ -2716,7 +2840,8 @@ function MarketTab({ notify, markets, onMutation, ops, favorites }) {
2716
2840
  labels: detailLabels,
2717
2841
  busy: activeInstallTarget === detailItem.id,
2718
2842
  onClose: () => setDetailId(null),
2719
- onInstall: (it2) => doInstall(it2)
2843
+ onInstall: (it2) => doInstall(it2),
2844
+ profileKind
2720
2845
  }) : null
2721
2846
  );
2722
2847
  }
@@ -3660,7 +3785,7 @@ function MarketPanel({ onClose }) {
3660
3785
  h(
3661
3786
  "div",
3662
3787
  { className: "dshm-body" },
3663
- tab === "market" ? h(MarketTab, { notify, markets, onMutation: refreshViews, ops, favorites }) : null,
3788
+ tab === "market" ? h(MarketTab, { notify, markets, onMutation: refreshViews, ops, favorites, profileKind: profile?.kind ?? null }) : null,
3664
3789
  tab === "installed" ? h(InstalledTab, { notify, installed, onMutation: refreshViews, ops }) : null,
3665
3790
  tab === "settings" ? h(SettingsTab, { notify, onRegistryChanged }) : null,
3666
3791
  h(OperationsPanel, {
@@ -220,6 +220,29 @@ async function runChain(cfg, opts, ctrl) {
220
220
  catalog: cached.catalog,
221
221
  };
222
222
  }
223
+ if (cached && pin === null) {
224
+ // 0.9.14 SWR:过期 → 同步回 stale,force flight 后台自愈(force 分支跳过快速路径,天然无递归;
225
+ // signal 剥离——原请求 abort 不得经 releaseFlight 腰斩后台 flight,与 registry SWR 对齐)
226
+ const bg = fetchCommunityCatalog(cfg, { ...opts, force: true, signal: undefined })
227
+ .finally(() => {
228
+ const i = backgroundRefreshes.indexOf(bg);
229
+ if (i >= 0)
230
+ backgroundRefreshes.splice(i, 1);
231
+ });
232
+ backgroundRefreshes.push(bg);
233
+ void bg.catch(() => undefined);
234
+ return {
235
+ state: stateOf({
236
+ status: 'stale',
237
+ version: cached.meta.version,
238
+ checkedAt: cached.meta.checkedAt,
239
+ fetchedAt: cached.meta.fetchedAt,
240
+ route: cached.meta.route,
241
+ count: cached.catalog.plugins.length,
242
+ }),
243
+ catalog: cached.catalog,
244
+ };
245
+ }
223
246
  }
224
247
  const errors = [];
225
248
  let version = pin;
@@ -433,3 +456,10 @@ export async function getCommunitySummary(primaryEntries, cfg = {}, opts) {
433
456
  const outcome = await communityOutcome(task, opts.deadlineAt, primaryEntries);
434
457
  return outcome.summary;
435
458
  }
459
+ // 0.9.14 SWR:过期触发即登记的后台自愈 flight(waiter 隔离;flight 本体走 fetchCommunityCatalog
460
+ // 的共享 flight 表,此处只留引用供测试钩子等待;.finally 自清)
461
+ const backgroundRefreshes = [];
462
+ /** 测试钩子:等待当前全部后台自愈 flight settle(allSettled,不抛)。 */
463
+ export function _waitForCommunityBackgroundForTests() {
464
+ return Promise.allSettled([...backgroundRefreshes]);
465
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 0.9.14 Task 4a:latest 探测缓存的唯一 owner——内存 Map + 磁盘信封层(write-through)。
3
+ * 磁盘文件 `<cacheRoot(profile)>/latest/<namespace>.json`:置于 nsDir 之外——registry.ts
4
+ * pruneCaches 只清扫 `<ns>/` 顶层 *.json(keep 集不含 latest),放 nsDir 内会被每次
5
+ * registry 刷新成功后的 prune 删除。信封 { version, namespace, entries };损坏/版本不符
6
+ * 整文件弃;全部磁盘操作静默失败(缓存是优化不是功能)。Task 4b 由 market.ts 接线
7
+ * (探测段前 ensureLatestCacheSeeded + writeLatestCache write-through 迁移)。
8
+ */
9
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { WEB_PROFILE, cacheRoot } from './env.js';
12
+ export const LATEST_CACHE_VERSION = 1;
13
+ const latestCache = new Map();
14
+ const LATEST_CACHE_MAX = 5000;
15
+ /** seed 硬上限淘汰窗口:超过 24h 的条目不再灌内存(write/seed 双侧一致) */
16
+ const SEED_MAX_AGE_MS = 24 * 60 * 60_000;
17
+ export function latestCacheKey(namespace, registryKey, item) {
18
+ const id = item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
19
+ return `${namespace}|${registryKey}|${id}`;
20
+ }
21
+ export function readLatestCache(key, ttlMin) {
22
+ const entry = latestCache.get(key);
23
+ if (!entry)
24
+ return null;
25
+ if (Date.now() - entry.at >= ttlMin * 60_000) {
26
+ latestCache.delete(key);
27
+ return null;
28
+ }
29
+ return entry.value;
30
+ }
31
+ function latestDir(profile) {
32
+ return join(cacheRoot(profile), 'latest');
33
+ }
34
+ function latestFile(namespace, profile) {
35
+ return join(latestDir(profile), `${namespace}.json`);
36
+ }
37
+ let persistChain = Promise.resolve();
38
+ /** 入队时快照单条(key+entry):flush 晚于内存 reset 也不丢数据;串行链防并发读改写互相覆盖。 */
39
+ function queuePersist(key, namespace, profile, entry) {
40
+ persistChain = persistChain.then(() => persistEnvelope(key, namespace, profile, entry)).catch(() => undefined);
41
+ }
42
+ async function persistEnvelope(key, namespace, profile, entry) {
43
+ let entries = {};
44
+ try {
45
+ const parsed = JSON.parse(await readFile(latestFile(namespace, profile), 'utf8'));
46
+ if (parsed && typeof parsed === 'object' &&
47
+ parsed.version === LATEST_CACHE_VERSION && parsed.namespace === namespace &&
48
+ parsed.entries && typeof parsed.entries === 'object') {
49
+ entries = parsed.entries;
50
+ }
51
+ }
52
+ catch {
53
+ // 无/损坏信封 → 从本条重建(静默)
54
+ }
55
+ const floor = Date.now() - SEED_MAX_AGE_MS;
56
+ for (const k of Object.keys(entries)) {
57
+ const e = entries[k];
58
+ if (!e || typeof e !== 'object' || typeof e.at !== 'number' || !Number.isFinite(e.at) || e.at < floor)
59
+ delete entries[k];
60
+ }
61
+ entries[key] = entry;
62
+ const envelope = { version: LATEST_CACHE_VERSION, namespace, entries };
63
+ await mkdir(latestDir(profile), { recursive: true });
64
+ const file = latestFile(namespace, profile);
65
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
66
+ await writeFile(tmp, JSON.stringify(envelope));
67
+ await rename(tmp, file);
68
+ }
69
+ /** 内存 set + write-through:ns 自 key 首段 `|` 前缀解析定位 `<ns>.json` 信封;profile 缺省 web(0.9.0 双 profile 段约定,调用方可显式传)。 */
70
+ export function writeLatestCache(key, value, profile = WEB_PROFILE) {
71
+ if (latestCache.size >= LATEST_CACHE_MAX) {
72
+ const oldest = latestCache.keys().next().value;
73
+ if (oldest !== undefined)
74
+ latestCache.delete(oldest);
75
+ }
76
+ latestCache.set(key, { at: Date.now(), value });
77
+ const ns = key.split('|', 1)[0];
78
+ if (ns !== 'host' && ns !== 'cli')
79
+ return;
80
+ const entry = latestCache.get(key);
81
+ if (!entry)
82
+ return;
83
+ queuePersist(key, ns, profile, entry);
84
+ }
85
+ // ---------- 懒 seed(每 (namespace,profile) 记忆化一次) ----------
86
+ const seeded = new Map();
87
+ export async function ensureLatestCacheSeeded(opts = {}) {
88
+ const namespace = opts.namespace ?? 'host';
89
+ const profile = opts.profile ?? WEB_PROFILE;
90
+ const memoKey = `${namespace}§${profile}`;
91
+ let task = seeded.get(memoKey);
92
+ if (!task) {
93
+ task = (async () => {
94
+ await persistChain.catch(() => undefined); // 先排空 write-through 队列(写→reset→seed 顺序亦确定成立)
95
+ await seedFromDisk(namespace, profile);
96
+ })().catch(() => undefined);
97
+ seeded.set(memoKey, task);
98
+ }
99
+ await task;
100
+ }
101
+ async function seedFromDisk(namespace, profile) {
102
+ let raw;
103
+ try {
104
+ raw = await readFile(latestFile(namespace, profile), 'utf8');
105
+ }
106
+ catch {
107
+ return; // 无信封 = 首次运行;静默
108
+ }
109
+ let envelope;
110
+ try {
111
+ const parsed = JSON.parse(raw);
112
+ if (!parsed || typeof parsed !== 'object')
113
+ return;
114
+ if (parsed.version !== LATEST_CACHE_VERSION || parsed.namespace !== namespace)
115
+ return;
116
+ if (!parsed.entries || typeof parsed.entries !== 'object')
117
+ return;
118
+ envelope = parsed;
119
+ }
120
+ catch {
121
+ return; // 损坏整文件弃(静默,行为退化为现状探测重放)
122
+ }
123
+ const floor = Date.now() - SEED_MAX_AGE_MS;
124
+ for (const [key, entry] of Object.entries(envelope.entries)) {
125
+ if (!entry || typeof entry !== 'object')
126
+ continue;
127
+ if (typeof entry.at !== 'number' || !Number.isFinite(entry.at) || entry.at < floor)
128
+ continue;
129
+ if (!entry.value || typeof entry.value !== 'object')
130
+ continue;
131
+ if (!latestCache.has(key))
132
+ latestCache.set(key, entry);
133
+ }
134
+ }
135
+ /** 测试钩子:清内存 Map + seed 记忆化(dsh-version.ts resetDshVersionCacheForTest 同款先例)。 */
136
+ export function resetLatestCacheForTest() {
137
+ latestCache.clear();
138
+ seeded.clear();
139
+ }
@@ -23,6 +23,7 @@ import { adaptCommunityCatalog } from './community-adapter.js';
23
23
  import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
24
24
  import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
25
25
  import { HttpError } from './httpx.js';
26
+ import { ensureLatestCacheSeeded, latestCacheKey, readLatestCache, writeLatestCache } from './latest-cache.js';
26
27
  import { verifyInstalledAdditions } from './install-guard.js';
27
28
  import { fetchTextLimited } from './httpx.js';
28
29
  import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
@@ -138,30 +139,6 @@ function deadlineRace(task, ms) {
138
139
  });
139
140
  });
140
141
  }
141
- const latestCache = new Map();
142
- const LATEST_CACHE_MAX = 5000;
143
- function latestCacheKey(namespace, registryKey, item) {
144
- const id = item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
145
- return `${namespace}|${registryKey}|${id}`;
146
- }
147
- function readLatestCache(key, ttlMin) {
148
- const entry = latestCache.get(key);
149
- if (!entry)
150
- return null;
151
- if (Date.now() - entry.at >= ttlMin * 60_000) {
152
- latestCache.delete(key);
153
- return null;
154
- }
155
- return entry.value;
156
- }
157
- function writeLatestCache(key, value) {
158
- if (latestCache.size >= LATEST_CACHE_MAX) {
159
- const oldest = latestCache.keys().next().value;
160
- if (oldest !== undefined)
161
- latestCache.delete(oldest);
162
- }
163
- latestCache.set(key, { at: Date.now(), value });
164
- }
165
142
  function probeTask(item, deps, timeoutMs, signal) {
166
143
  if (item.source === 'npm' && item.npm) {
167
144
  return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
@@ -524,6 +501,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
524
501
  let latestComplete = true;
525
502
  let latestTimedOut = false;
526
503
  if (withLatest && items.length > 0) {
504
+ // 0.9.14:latest 探测缓存已落盘——探测段前装载/排空磁盘信封(重启后零重放)
505
+ await ensureLatestCacheSeeded({ namespace, profile: opts.profile });
527
506
  // 先吃 cache 命中
528
507
  const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
529
508
  for (const item of items) {
@@ -553,7 +532,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
553
532
  const outcome = await probeWithBudget(task, perBudget, signal);
554
533
  if (outcome.ok && outcome.value) {
555
534
  applyProbe(item, outcome.value);
556
- writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
535
+ writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value, opts.profile);
557
536
  }
558
537
  else if (outcome.timeout) {
559
538
  item.latestError = '更新检查未完成:超时';
@@ -642,7 +621,9 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
642
621
  }
643
622
  const registryState = stateOf(loaded);
644
623
  matchInstalled(items, community.merged);
645
- await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
624
+ // 0.9.14:latest 探测缓存已落盘——探测段前装载/排空磁盘信封
625
+ await ensureLatestCacheSeeded({ namespace, profile: opts.profile });
626
+ await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000, profile: opts.profile });
646
627
  return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
647
628
  }
648
629
  /** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
@@ -662,11 +643,12 @@ function matchInstalled(items, merged) {
662
643
  async function probeLatest(items, ctx, d, rt) {
663
644
  await mapWithConcurrency(items, LATEST_WORKERS, async (item) => {
664
645
  const entry = ctx.merged.find((e) => matchInstalledByEntry(e, [item]));
665
- // latest 探测沿用 listMarket 的 TTL cache
646
+ // latest 探测沿用 listMarket 的 TTL cache;npm-only 条目(不在收录清单的已装包)也走
647
+ // latestCacheKey 归一格式(0.9.14:否则首段非 host/cli 会被落盘层静默跳过)
666
648
  const cacheKey = entry
667
649
  ? latestCacheKey(rt.namespace, ctx.registryAddress ?? '', entry)
668
650
  : item.source === 'npm'
669
- ? `npm-only|${rt.namespace}|npm:${item.pkg}`
651
+ ? latestCacheKey(rt.namespace, 'npm-only', { source: 'npm', id: item.pkg, npm: item.pkg })
670
652
  : null;
671
653
  if (cacheKey) {
672
654
  const cached = readLatestCache(cacheKey, ctx.ttlMin);
@@ -695,7 +677,7 @@ async function probeLatest(items, ctx, d, rt) {
695
677
  }
696
678
  if (value) {
697
679
  applyProbe(item, value);
698
- writeLatestCache(cacheKey, value);
680
+ writeLatestCache(cacheKey, value, rt.profile);
699
681
  }
700
682
  }
701
683
  catch (err) {
@@ -587,7 +587,13 @@ async function loadDefaultChain(cfg, opts, chain) {
587
587
  const errors = [];
588
588
  const startedAt = Date.now();
589
589
  const routes = opts.defaultRoutes ?? DEFAULT_URLS;
590
- for (const candidate of routes) {
590
+ // 0.9.14 线路粘性:cache 记录的上次成功线路排最前(稳定排序保序;cache 缺失 = 原序)。
591
+ // 粘性读不判 TTL——过期 cache 的 source 依然是有效线路记忆;force 同样生效。
592
+ const stickySource = (await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY, chain.profile))?.source;
593
+ const ordered = stickySource
594
+ ? [...routes].sort((a, b) => Number(b.source === stickySource) - Number(a.source === stickySource))
595
+ : routes;
596
+ for (const candidate of ordered) {
591
597
  try {
592
598
  const { registry, finalUrl } = await fetchRegistryFromUrl(candidate.url, attemptTimeoutMs(cfg, opts, startedAt), opts.signal);
593
599
  const fetchedAt = new Date().toISOString();
@@ -707,17 +713,52 @@ async function loadCustomChain(address, cfg, opts, chain) {
707
713
  registry: { version: 1, plugins: [] },
708
714
  };
709
715
  }
710
- /** active 读取:default/custom 各自 fallback;网络成功后可在本 namespace 内 prune。 */
716
+ // 0.9.14 SWR:过期 cache 同步先回 stale,网络刷新转入后台单飞(key = namespace§profile§cacheKey;
717
+ // 单飞闭包捕获本次调用的 cfg/opts——含 defaultRoutes 测试缝,signal 除外;.finally 自清)。
718
+ const backgroundFlights = new Map();
719
+ /** 测试钩子:等待当前全部后台刷新单飞 settle(allSettled,不抛)。 */
720
+ export function _waitForRegistryBackgroundForTests() {
721
+ return Promise.allSettled([...backgroundFlights.values()]);
722
+ }
723
+ /** 后台刷新体:与同步链同款两分支 + ready 后 prune;失败静默(调用方已拿到 stale)。 */
724
+ async function backgroundChain(cfg, opts, address, namespace, profile) {
725
+ const bgOpts = { ...opts, signal: undefined };
726
+ if (address.kind === 'default') {
727
+ const loaded = await loadDefaultChain(cfg, bgOpts, { namespace, profile, includeBundled: true });
728
+ if (opts.prune !== false && loaded.status === 'ready')
729
+ await pruneCaches(namespace, null, profile);
730
+ return;
731
+ }
732
+ const loaded = await loadCustomChain(address, cfg, bgOpts, { namespace, allowCacheFallback: true, profile });
733
+ if (opts.prune !== false && loaded.status === 'ready')
734
+ await pruneCaches(namespace, address.cacheKey, profile);
735
+ void loaded;
736
+ }
737
+ /** active 读取:default/custom 各自 fallback;TTL 内直读;过期先回 stale(SWR)+ 后台单飞自愈;force 始终同步强刷;网络成功后可在本 namespace 内 prune。 */
711
738
  export async function loadRegistry(cfg = {}, opts = {}) {
712
739
  const namespace = opts.namespace ?? 'host';
713
740
  const profile = opts.profile ?? WEB_PROFILE;
714
741
  const address = parseRegistryAddress(cfg.registryUrl);
715
742
  const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
743
+ const cacheKey = address.kind === 'default' ? DEFAULT_CACHE_KEY : address.cacheKey;
716
744
  if (!opts.force) {
717
- const cacheKey = address.kind === 'default' ? DEFAULT_CACHE_KEY : address.cacheKey;
718
745
  const cached = await readCacheFile(namespace, cacheKey, profile);
719
- if (cached && cacheFresh(cached, ttlMin))
746
+ if (cached) {
747
+ if (cacheFresh(cached, ttlMin))
748
+ return loadedFromCacheFile(cached, address.normalized, []);
749
+ // 0.9.14 SWR:过期 → 同步回 stale;无同 key 单飞才后台起链(不接调用者 signal,失败静默)
750
+ const flightKey = `${namespace}§${profile}§${cacheKey}`;
751
+ if (!backgroundFlights.has(flightKey)) {
752
+ const flight = backgroundChain(cfg, opts, address, namespace, profile)
753
+ .catch(() => undefined)
754
+ .finally(() => {
755
+ if (backgroundFlights.get(flightKey) === flight)
756
+ backgroundFlights.delete(flightKey);
757
+ });
758
+ backgroundFlights.set(flightKey, flight);
759
+ }
720
760
  return loadedFromCacheFile(cached, address.normalized, []);
761
+ }
721
762
  }
722
763
  if (address.kind === 'default') {
723
764
  const loaded = await loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "id": "dsh-m",
6
6
  "name": "DSH Marketplace",
7
- "description": "自定义清单的 DSH 插件市场,收录/安装/卸载/升级;已适配 0.1.5-rc.1 至 0.2.0-rc.1/rc.2 各代,详见仓库。",
7
+ "description": "自定义清单的 DSH 插件市场,收录/安装/卸载/升级;已适配 DSH 0.2.0-rc.2,支持 web 与 desktop 双 profile,详见仓库。",
8
8
  "category": "market",
9
9
  "tags": [
10
10
  "市场",
@@ -148,16 +148,17 @@
148
148
  {
149
149
  "id": "dsh-quota-watch",
150
150
  "name": "DSH Quota Watch",
151
- "description": "侧栏额度监控卡片:显示 GLM Coding Plan 窗口与 Copilot 额度快照,凭据宿主解析,不留存历史;已适配 0.1.7-rc.2,详见仓库。",
151
+ "description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。",
152
152
  "category": "tools",
153
153
  "tags": [
154
154
  "配额",
155
- "监控",
155
+ "悬浮球",
156
156
  "GLM",
157
157
  "Copilot"
158
158
  ],
159
159
  "verified": [
160
- "0.1.7-rc.2"
160
+ "0.1.7-rc.2",
161
+ "0.1.0"
161
162
  ],
162
163
  "source": "npm",
163
164
  "npm": "@iasiv5/dsh-quota-watch",