dsh-m 0.9.60 → 0.9.62

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/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.62 修复:灯箱控件浅色模式下不可见——影院遮罩自持 + 控件深色玻璃芯片 + 加载转圈/淡入
12
+
13
+ - **根因(2026-10-09 用户浅/深双模式实拍)**:dsh-skins ADR-0007 浮层毛玻璃并集按 `[role="dialog"][aria-modal="true"]` 命中灯箱根(灯箱根带 dialog 语义),浅色模式下 86% 暖白 tint + blur14 把影院式黑遮罩改成了浅色毛玻璃;而 ‹›/✕ 沿用基础 `.dsvm-btn` 白色半透配方(`rgba(255,255,255,.14)` 底 + 白字白边),浅底上整体隐形。深色模式走皮肤 `rgb(18,18,26)` 分支尚可读,故「浅色看不清、深色还行」。无皮肤的原生形态(黑遮罩恒在)无症状——这正是它漏网到 0.9.61 的原因。
14
+ - **双侧修复**:① dsh-m 侧(本版):灯箱 `background` 加 `!important` 自持——「深色遮罩 + 浅色控件」是影院面的组件自有可读性不变量,不随颜色模式/皮肤翻转;marketplace 社区皮肤不可枚举,不变量必须组件自己兜底(`!important` 清单定长 4,回归门钉住,新增须显式登记)。② dsh-skins 侧:三皮肤 `[role="dialog"][aria-modal="true"]` 追加 `:not(.dsvm-lightbox)` 豁免(1.5.1,ADR-0007 amendment)——语义上「弹窗毛玻璃」本就不该染指图片影院。
15
+ - **控件深色玻璃芯片**:‹›/✕ 从裸透明白改为与底部 pill 同族的深色芯片(slate-900 55% + 白描边 + blur8 + 投影)——深遮罩上靠描边/blur/投影出层次,浅遮罩上芯片本身就是对比面,任何颜色模式与皮肤下都可读;显式 `:hover` 提亮(特异性 0,3,0 压基础 `.dsvm-btn:hover` 0,2,0,免疫源顺序——评审 R1 同族教训)。
16
+ - **加载指示与淡入(UX 补强)**:灯箱走链期间(weserv 慢速/8s 守卫换层/直连大图)此前是空黑黑洞,现转圈占位(`useImgChain` 暴露 `settled`;点转圈视为点遮罩可关——急躁关闭是合理逃生门);图片 onLoad 后 160ms 淡入换场;`prefers-reduced-motion` 下停用淡入、转圈降速。
17
+ - **验证**:1283 项测试全绿、typecheck 零错误;`client-lightbox.test.mjs` 新增 6 项结构锚(遮罩 `!important` 定长清单/芯片规则与显式 hover/转圈门控/淡入类名/settled 暴露/reduced-motion),0.9.60 批次既有锚(portal/复合选择器/拖拽带/焦点还原/序号 key)全数保持。
18
+
19
+ ### 0.9.61 修复:大陆浏览器拉不到插件截图/图标——图片加载链 weserv 优先双兜底
20
+
21
+ - **根因(2026-10-09 实测)**:截图(raw.githubusercontent.com)与图标(github.com 头像)由用户浏览器直连拉取——服务器侧 200/0.2s,大陆浏览器路径时断时通,当日故障样本 dsh-wallpaper-engine 五图(22.5MB,含 10.4MB GIF)缩略图条与灯箱全空;0.9.60 已排除灯箱 v2 回归,图标「能显示」实为本地字母兜底。对标 dsh-market 1.66.14:缩略图无条件 weserv(其源码实测大陆 1.39s/23KB vs 原图 41KB)但灯箱直连零兜底——大陆点开大图同款黑洞。
22
+ - **图片加载链(ADR-0014)**:三类消费方(缩略图 h=300 / 灯箱 w=1600 / 图标 h=96)共用 `useImgChain`——weserv 层(`fit=inside&we=1&output=webp&q=80` 服务端缩放转码)优先,失败或 8s 人工超时(仅此层;直连层零人工超时防误杀慢速合法下载)换原图直连,再败走终态:缩略图剔除(全败隐藏整条)、灯箱占位「⚠ 失败 + 重试 + 打开原图 ↗」、图标字母渐变兜底。赢家记忆按 raw/avatar 两桶记最近成功层,**链启动时快照次序**——同批多图竞态下兄弟图不因偏好翻转跳层(执行期实证:缩略图 1 经直连成功翻转偏好后 2/3 全灭的竞态);链内前进序感知(偏好为 direct 时 direct 败仍按序试 weserv,固定阶梯会跳过兜底直判死)。四处 shots 基准同步换 visible(整条门控/截图条 map/灯箱门控/键盘导航+effect 依赖),列表保持全量 map + `${i}:${src}` 稳定 key,灯箱 index 钳制防越界。
23
+ - **配套**:纯逻辑入 `src/client/img-chain.js`(URL 构造/桶分类/序感知 tier 机/赢家记忆,`WESERV_BASE` 单常量——未来设置项只改一处);GLOSSARY 增「图片加载链(Image Chain)」一条;探针扩展 A16-A24(断直连/断 weserv/双断占位+重试/weserv 挂起 8s 守卫/赢家记忆/缩略图剔除/direct-first 换层 Probe 场景)+ `--live` 真网压缩实证;执行期探针 harness 三大确定性根基:**每场景全新 page(独立 context,消灭路由复挂竞态与跨场景解码复用)**、代次化 URL(凡需失败的 URL 全页生命周期唯一)、层序断言走 reqLog(page.on('request') 不依赖路由拦截)。
24
+ - **验证**:1256 项测试全绿(基线 1236 + 新增 20)、typecheck 零错误;探针 31/31 三连跑稳定;`--live` 压缩实证 mascot-drawer.png 直连 2859KB vs weserv 151KB(≈18.9×)。评审吸收轮(完整多轮独立 subagent 评审):R1-1【阻断】8s 守卫在成功加载后不清除——成功图 8s 后被强制换层重载(大陆场景缩略图误剔除/图标字母化/灯箱变占位),修以 settled 语义(onLoad 置真、链启动/翻页重置、timer 四条件守卫含空 url),并新增探针 A25(成功驻留 >8.5s 行为钉子,旧代码必红)与计时门控源锚;R1-2 A22 补负向断言(导航后零 weserv 形态请求)、A24 断言首请求形态(赢家记忆翻转获得证伪力);R1-3 freshState 清 reqLog(场景封闭);R1-5 url 变化改渲染期 derived-state 重置(消除中间帧幽灵请求);R1-6 --live 双侧校验 r.ok + image/*(错误页不得假 PASS);R2-3 advance 防御性 settled 重置。第二轮终局裁决:共识 approve。遗留后续项:中间帧消除的行为级探针(现由源锚防回归)。
25
+
11
26
  ### 0.9.60 修复:截图灯箱窗口态无导航/无法退出——createPortal 逃出面板包含块 + 控件常驻
12
27
 
13
28
  - **根因**:灯箱 `position:fixed` 是 `.dshm-panel`(`backdrop-filter` + `overflow:hidden`)的后代,面板盒成了它的包含块——图片按视口单位放大(旧 94vw/80vh),在 ≤680px 高的窗口态面板里必然溢出,in-flow 的 ‹›/圆点被裁出屏外,又没有 ✕;全屏态面板恰为视口才「碰巧」可见(即「只有全屏才有导航按钮,窗口态图片占满小屏且无法退出」)。
@@ -446,6 +461,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
446
461
 
447
462
  ## English
448
463
 
464
+ ### Fixed in 0.9.62 — lightbox controls invisible in light color schemes: self-owned cinema backdrop + dark glass control chips + loading spinner/fade
465
+
466
+ - **Root cause (user photos, both color schemes, 2026-10-09)**: the dsh-skins ADR-0007 overlay-frost union targets `[role="dialog"][aria-modal="true"]` - which matches the lightbox root (it carries dialog semantics). In light schemes the 86% warm-white tint + blur14 turned the cinema-black backdrop into light frosted glass, while ‹›/✕ reuse the base `.dsvm-btn` translucent-white recipe (`rgba(255,255,255,.14)` fill, white glyph/border) - invisible on a light surface. Dark schemes hit the skin's `rgb(18,18,26)` branch and stayed readable, hence "light mode unreadable, dark mode okay". Vanilla installs (backdrop always near-black) never showed it - which is how it slipped past 0.9.61.
467
+ - **Two-sided fix**: ① dsh-m side (this release): the lightbox `background` is now `!important`-owned - "dark backdrop + light controls" is the component's own readability invariant for a cinema surface and must not flip with color schemes or skins; marketplace community skins are un-enumerable, so the component must defend itself (the `!important` inventory is pinned at a fixed length of 4 by the regression gate; additions must register explicitly). ② dsh-skins side: all three skins exempt `.dsvm-lightbox` via `:not(...)` (1.5.1, ADR-0007 amendment) - semantically a "dialog frost" never should have tinted an image theater.
468
+ - **Dark glass control chips**: ‹›/✕ move from bare translucent white to a chip in the same family as the bottom pill (slate-900 55% + white border + blur8 + shadow) - on dark backdrops the border/blur/shadow provide edge definition, on light backdrops the chip itself is the contrast surface; readable under any scheme or skin. Explicit `:hover` brightening (specificity 0,3,0 beats base `.dsvm-btn:hover` 0,2,0 regardless of source order - the R1 family lesson).
469
+ - **Loading indicator & fade (UX)**: while the image chain runs (slow weserv / 8s guard tier-swap / large direct original) the lightbox used to be a silent black void; it now shows a spinner (`useImgChain` exposes `settled`; clicking the spinner counts as a backdrop click - an impatient close is a legitimate escape hatch). The image fades in over 160ms on onLoad; under `prefers-reduced-motion` the fade is disabled and the spinner slows down.
470
+ - **Verification**: 1283 tests green, typecheck clean; `client-lightbox.test.mjs` gains 6 structural anchors (backdrop `!important` fixed-length inventory / chip rules with explicit hover / spinner gating / fade class / settled exposure / reduced-motion); all 0.9.60-era anchors (portal / compound selectors / titlebar offset / focus restore / index keys) retained.
471
+
472
+ ### Fixed in 0.9.61 — screenshots/icons unreachable from mainland browsers: weserv-first dual-fallback image chain
473
+
474
+ - **Root cause (measured 2026-10-09)**: screenshots (raw.githubusercontent.com) and icons (github.com avatars) load browser-direct - fine from the server (200/0.2s), intermittently dead from mainland browsers. That day's failure sample dsh-wallpaper-engine (5 images, 22.5MB incl. a 10.4MB GIF) rendered an empty strip and a black lightbox; 0.9.60 ruled out a lightbox-v2 regression, and "working" icons were the local letter fallback. Benchmark dsh-market 1.66.14: thumbnails unconditionally via weserv (their in-source mainland measurement: 1.39s/23KB vs 41KB original) but the lightbox loads direct with zero fallback - the same mainland black hole.
475
+ - **Image chain (ADR-0014)**: three consumers (thumbnail h=300 / lightbox w=1600 / icon h=96) share `useImgChain` - weserv tier first (`fit=inside&we=1&output=webp&q=80` server-side resize/recode), on failure or an 8s guard (that tier only; the direct tier gets no artificial timeout so slow-but-legal downloads are never killed) fall back to the original URL, then to per-consumer finals: thumbnails dropped (whole strip hidden when all fail), lightbox placeholder "failed + retry + open original", icon letter fallback. Winner memory per service bucket (raw/avatar) remembers the last successful tier, and **each chain snapshots the order at start** - sibling images racing in the same batch never skip a tier because a preference flipped mid-flight (found in execution: thumbnail 1 succeeding via direct flipped the preference and killed thumbnails 2/3); advancement is order-aware (with direct preferred, a direct failure still tries weserv - a fixed ladder would skip the fallback entirely). All four shots references switch to visible (strip gating / strip map / lightbox gating / keyboard nav + effect deps), the strip keeps a full-list map with stable `${i}:${src}` keys, and the lightbox index is clamped against shrinkage.
476
+ - **Supporting**: pure logic in `src/client/img-chain.js` (URL builder / bucket classifier / order-aware tier machine / winner memory; `WESERV_BASE` single constant - a future setting changes one line); one GLOSSARY term (Image Chain); probe extended A16-A24 (direct-blocked / weserv-blocked / dual-block placeholder+retry / weserv-hang 8s guard / winner memory / strip removal / direct-first Probe scenario) plus `--live` real-network compression; three probe-harness determinism pillars learned during execution: **a fresh page (fresh context) per scenario** (kills route-remount races and cross-scenario decoded-image reuse), generation URLs (`?g=N` - any URL that must fail is unique for the page lifetime), and tier-order assertions via reqLog (`page.on('request')` survives route interception quirks).
477
+ - **Verification**: 1256 tests green (1236 baseline + 20 new), typecheck clean; probe 31/31 stable across three runs; `--live` compression: mascot-drawer.png direct 2859KB vs weserv 151KB (~18.9x). Review absorption round (full multi-round independent subagent review): R1-1 [blocker] the 8s guard survived a successful load - a displayed image was force-advanced 8s later (mainland: thumbnails wrongly dropped, icons degraded, lightbox mutating into the placeholder); fixed with settled semantics (set by onLoad, reset on chain start/navigation, four-condition timer guard incl. empty url), pinned by new probe A25 (dwell >8.5s; the old code cannot pass it) plus timer-gating source anchors. R1-2 A22 gains the negative assertion (zero weserv-form requests after navigation) and A24 asserts the first request's form (winner-memory flip now falsifiable). R1-3 freshState clears reqLog (scenario closure). R1-5 url change resets via render-phase derived state (the one-frame ghost request is gone). R1-6 --live validates r.ok + image/* on both fetches. R2-3 defensive settled reset in advance. Round-2 verdict: consensus approve. Deferred: a behavioral probe for the middle-frame elimination (currently pinned by source anchors).
478
+
449
479
  ### Fixed in 0.9.60 — screenshot lightbox unusable when windowed: portal out of the panel containing block + always-visible controls
450
480
 
451
481
  - **Root cause**: the lightbox was a `position:fixed` descendant of `.dshm-panel` (`backdrop-filter` + `overflow:hidden`), so the panel box became its containing block. The image sized in viewport units (old 94vw/80vh) inevitably overflows the ≤680px-tall windowed panel and the in-flow ‹›/dots row gets clipped off-screen, with no close button at all; fullscreen only worked because the panel then equals the viewport (the reported "nav buttons appear only in fullscreen; windowed fills the small screen and cannot exit").
package/docs/DESIGN.md CHANGED
@@ -71,12 +71,12 @@
71
71
 
72
72
  - **双层模型**:主清单(§2.1–2.4 全部语义不变)之上叠加只读的**社区清单**——awesome-dsh-plugin 维护的全量社区目录(4,377+ 条,日增约 62)。**合并市场 = 主清单 ∪ 社区清单去重(主清单恒优先)**,GUI / agent 工具 / CLI 三端同源同语义。本节修订 §9.1 Q25「不做运行时多源合并」,架构决策见 [ADR-0003](./adr/0003-community-catalog-merge.md)。
73
73
  - **数据锚定 npm 包(Q39)**:`dsh-plugin-catalog`(CC0-1.0,版本 `YYYY.MDD.RUN`)为唯一数据锚。获取链:`registry.npmjs.org` dist-tags 探测最新版本(几 KB)→ **版本未变不重拉正文(版本号即 revalidate 验证器)**→ jsDelivr 按精确版本直取 `plugins.json`(版本 pin 后内容不可变,CDN 缓存无害)→ npmmirror files API → unpkg 按精确版本直取(末位兜底;均为纯 JSON 文件线路,不引入 tar 解包依赖)。npm 精确版本不可变 = 完整性锚;**不做包内快照兜底**(对日增 62 条的目录,过期数据不得冒充最新——陈旧必须显式标注),失败如实报错(发生了什么 / 为什么 / 现在怎么办)。
74
- - **目录适配层**:原生条目 → 收录条目。`description.zh`(缺省回退 `en`;>500 字符截断并计数)→ description;`npm` 非空 → `source: npm`(`url` 为 github.com 形态时同时派生 `github` owner/repo,子包取 repo 根,0.9.51 起),否则 `source: github`(owner/repo 取自 url);`url` → homepage;tags 置空;**分类保留原生值不转译**(开放集,Q40);`stars/downloads/capabilities/screenshots` 等不进收录条目本体,仅在市场层作为旁路数据透传展示。id 合成:原生 `name` 是裸名(实测 4,377 条中 190 个重名),合成小写 `owner--name` 形态并满足 v1 id 规则(非法字符折叠为 `-`,超 64 字符截断加哈希尾缀),适配层内冲突追加序号。
74
+ - **目录适配层**:原生条目 → 收录条目。`description.zh`(缺省回退 `en`;>500 字符截断并计数)→ description;`npm` 非空 → `source: npm`(`url` 为 github.com 形态时同时派生 `github` owner/repo,子包取 repo 根,0.9.51 起),否则 `source: github`(owner/repo 取自 url);`url` → homepage;tags 置空;**分类保留原生值不转译**(开放集,Q40);Community Catalog 的 `stars/downloads/capabilities/screenshots` 等不进收录条目本体,仅在 CommunityEntry/市场层作为旁路数据透传;Primary 截图另按 §2.6 从作者仓库读取,不进入 MarketItem。id 合成:原生 `name` 是裸名(实测 4,377 条中 190 个重名),合成小写 `owner--name` 形态并满足 v1 id 规则(非法字符折叠为 `-`,超 64 字符截断加哈希尾缀),适配层内冲突追加序号。
75
75
  - **校验语义(Q43)**:社区清单独立校验——容器层严格(顶层键白名单、`plugins` 数组、32 MiB / 30,000 条上限,超限**整份拒收**)+ 条目层宽松(单条不合格**跳过 + 计数**,汇入 warnings,不整份拒绝)。主清单严格校验(§2.2)不变。**无 npm 的 monorepo 子包条目跳过并计数**(repo 根不是正确安装目标);有 npm 的子包条目按 npm 安装、正常收录;tarball-only 条目按 github 源收录(dsh-m 不安装 tarball)。
76
76
  - **分类(Q40)**:精选分类(5 个,主清单 schema 不变)∪ 社区分类(开放集)。已知 20 个社区分类带中文标签进筛选栏「社区」组;`ui`/`tools`/`market` 三 id 与精选同名、共享过滤桶;上游新增的未知分类原样渲染进「社区·新分类」临时组,发版收录标签。
77
77
  - **降级语义(Q42)**:主清单 unavailable + 社区可用 → 显示社区条目 + 顶部错误横幅 + 安装不禁用;社区 unavailable → 主清单照常 + 静默 notice;探测失败时回落 `<ns>/awesome/` **运行时缓存**并显式标注 stale(绝不冒充 ready)——运行时缓存语义与主清单自定义源一致,被禁止的只是**包内快照**;社区**绝不**回退主清单伪装。(**0.9.15 修订**:SWR 常态化后市场页不再显示「缓存快照」临时横幅——stale 标注移至设置页社区卡,「绝不冒充 ready / 过期可被观察到」的诚实约束不变。)
78
78
  - **开关与缓存**:`communityCatalog`(默认 true,volatile live 生效)+ `communityCatalogPin`(可选锁 npm 版本);CLI 用 `DSHM_COMMUNITY_CATALOG=0` 退出。社区缓存放 `<namespace>/awesome/` 子目录(不参与 `pruneCaches` 的顶层 `*.json` 清理),正文按版本文件缓存,`fetchedAt`/版本号/线路进设置页展示;TTL 复用 `cacheTtlMin`(只约束 dist-tags 探测频率)。
79
- - **市场行为(Q45/Q46)**:默认排序主清单置顶(组内维持原顺序)+ 社区按 30 天下载量降序(无数据按名称);搜索同时匹配中英文描述;「只看主清单」chip 常驻筛选栏首位。**探测边界**:市场浏览页对社区 npm 条目做 latest 探测(registry 无配额限制);社区 github 条目**不做**浏览页 REST 探测(GitHub 匿名 60 次/小时在 50 条/页 × 2 调用下不可控),更新检查收敛到详情/安装时的按需解析——配额耗尽时已有可读提示(versions.ts `githubRateLimitMessage`)。**已装页豁免**:探测对象受已装数量天然约束,继续探测;计量在**真实 GitHub HTTP 请求层**(一次 `githubLatestTag` ≈ 1–3 个请求:release 路径 1–2 个、fallback 路径 releases→tags→commits 最多 3 个):单请求 ≤25 次、宿主进程滚动 1 小时 ≤50 次(**仅作用于被动探测**——用户主动 install/upgrade/诊断不经此预算)、同仓库 in-flight single-flight、超限标 `latestError` 不阻塞列表——否则用户装的社区 github 插件永远没有更新徽标。独立 CLI 进程不共享宿主内预算,文档如实标注 best-effort(并发场景不承诺 60/h 绝不耗尽)。能力披露(capabilities/红线)只在详情折叠区展示(缺省 = 未扫描 ≠ 未检出),卡片不打标(Q44,防警告疲劳);截图仅详情层加载(GitHub 图床白名单由上游保证)。
79
+ - **市场行为(Q45/Q46)**:默认排序主清单置顶(组内维持原顺序)+ 社区按 30 天下载量降序(无数据按名称);搜索同时匹配中英文描述;「只看主清单」chip 常驻筛选栏首位。**探测边界**:市场浏览页对社区 npm 条目做 latest 探测(registry 无配额限制);社区 github 条目**不做**浏览页 REST 探测(GitHub 匿名 60 次/小时在 50 条/页 × 2 调用下不可控),更新检查收敛到详情/安装时的按需解析——配额耗尽时已有可读提示(versions.ts `githubRateLimitMessage`)。**已装页豁免**:探测对象受已装数量天然约束,继续探测;计量在**真实 GitHub HTTP 请求层**(一次 `githubLatestTag` ≈ 1–3 个请求:release 路径 1–2 个、fallback 路径 releases→tags→commits 最多 3 个):单请求 ≤25 次、宿主进程滚动 1 小时 ≤50 次(**仅作用于被动探测**——用户主动 install/upgrade/诊断不经此预算)、同仓库 in-flight single-flight、超限标 `latestError` 不阻塞列表——否则用户装的社区 github 插件永远没有更新徽标。独立 CLI 进程不共享宿主内预算,文档如实标注 best-effort(并发场景不承诺 60/h 绝不耗尽)。能力披露(capabilities/红线)只在详情折叠区展示(缺省 = 未扫描 ≠ 未检出),卡片不打标(Q44,防警告疲劳);社区截图仅详情层加载且其 GitHub 图床白名单由上游保证;Primary 作者清单按 §2.6 由 Host 与客户端消费端各自校验。
80
80
 
81
81
  ### 2.6 双清单分区市场与交互升级(0.7.0 grilling 定稿 2026-09-29)
82
82
 
@@ -92,6 +92,7 @@
92
92
  - 搜索:相关性加权管线——NFKC 归一化 + 中西文边界插空格 + 字段权重(name/npm > owner > 描述 > 分类)+ 命中类型加分 + 按条目缓存归一化结果;输入 250ms debounce + IME composition 全程处理 + draft/已提交 query 分离。**0.9.25 起搜索跨区全局**(两分区搜索框同语义):摘要行报两分区精确命中数(`sourceCounts`),结果页内按精选段置顶分组,分类 chips 搜索态隐藏、「筛选」按钮随摘要行保留(页大小组)。**0.9.26 补充**:GUI 跨区搜索通道精选命中稳定前置(`curatedFirst`,分区内相关序不变;tools/CLI 不传,三端同序不变)——摘要行全局计数与首页所见一致,弱命中精选不被 downloads tie-break 埋进后页。
93
93
  - 描述 5 行钳制(真实溢出才显示展开钮);**数据层双语**——描述按 UI 语言取 zh/en(数据现成),UI 完整双语字典列 backlog。
94
94
  - **详情 = Modal 且为卡片超集**(「detail 显示少于摘要就是倒退」):byline / 分类 / 收录日期 / 下载量窗口三要素(计数 + 窗口 + 核对时间)/ 描述全文 / 截图灯箱(←→/Esc、**禁自动轮播**)/ 能力披露 + 红线(**默认收起**)/ 安装命令折叠行(0.9.14 起按上下文显隐:desktop 上下文与已安装条目整行隐藏——命令两来源恒为 `--profile web` 语义,desktop 装机走弹窗内安装按钮)/ deprecated 替代链接;精选条目超集另加 verified 与 tags。**安装确认走 Modal**。安装信息就地显示(0.9.15):进行中进度行与终态结果行挂 Modal 内、底层同源进度行让位——展示派生自全局操作记录,§2.6「状态不挂卡片」所有权模型不变。卡片瘦身:能力披露、截图、安装命令全部迁出卡片(Q44 精神「只在详情层、默认收起」不变,载体升级)。
95
+ - **详情截图来源(2026-10-09,[ADR-0015](./adr/0015-primary-screenshot-manifest.md))**:Community live 条目沿用 `CommunityEntry.screenshots`(来自版本化 `dsh-plugin-catalog`);Primary DetailModal 调 GUI Host method `primary-screenshots({ id })`,Host 按 active Primary Registry 精确查 id 与 `github`,仅当 `github` 存在时懒读仓库根 `screenshots.json`(HEAD)。清单为顶层最多 8 个仓库相对路径;缺 github、缺文件、无效内容或网络失败均返回空列表,不回退 Community Catalog、README 或 npm repository。清单只在 Modal state/Host 内存缓存(成功结果 10 分钟、空/失败结果负缓存 1 分钟),不进入 `MarketItem`、市场 localStorage 快照、收藏快照、Agent 工具或 CLI;最终图片仍经现有 `safeScreenshots` 与 [ADR-0014](./adr/0014-image-chain-weserv-first.md) 的 `useImgChain`。`source='primary'` 是 market 分区参数,与 `primary-screenshots` Host method 分离;现有 Community Zone 可独立加载社区数据。
95
96
  - **全局操作记录**:每个变更操作一条 record(`queued / running / input=冲突待决 / done / warned / failed`),**状态不挂卡片**——翻页、搜索、切 tab 不丢;**localStorage 持久化队列**,宿主重载恢复时逐条校验「此刻仍成立才执行,否则报告」;覆盖安装、升级、卸载、开关。已装侧跟随一致化:「全部更新 (N)」批量入口 + tab 更新红点 + 卡片视觉与发现侧同体系;组管理、个人备注列 backlog。
96
97
  - **收藏**:浏览器 localStorage 本地收藏(不进 profile、不进服务端);收藏区 stale 条目(目录中已下架)单独提示 + 一键清理。
97
98
  - **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
@@ -478,6 +478,66 @@ var init_lightbox = __esm({
478
478
  }
479
479
  });
480
480
 
481
+ // src/client/img-chain.js
482
+ var img_chain_exports = {};
483
+ __export(img_chain_exports, {
484
+ WESERV_BASE: () => WESERV_BASE,
485
+ WESERV_TIMEOUT_MS: () => WESERV_TIMEOUT_MS,
486
+ needsTimeout: () => needsTimeout,
487
+ nextTier: () => nextTier,
488
+ preferredTier: () => preferredTier,
489
+ rememberSuccess: () => rememberSuccess,
490
+ resetTierPreferences: () => resetTierPreferences,
491
+ serviceBucketOf: () => serviceBucketOf,
492
+ tierOrder: () => tierOrder,
493
+ weservUrl: () => weservUrl
494
+ });
495
+ function weservUrl(src, { w, h: h2 } = {}) {
496
+ const bare = String(src).replace(/^https?:\/\//, "");
497
+ const size = Number.isFinite(w) && w > 0 ? `w=${Math.floor(w)}` : Number.isFinite(h2) && h2 > 0 ? `h=${Math.floor(h2)}` : "";
498
+ const sizePart = size ? `&${size}` : "";
499
+ return `${WESERV_BASE}url=${encodeURIComponent(bare)}${sizePart}&fit=inside&we=1&output=webp&q=80`;
500
+ }
501
+ function serviceBucketOf(url) {
502
+ try {
503
+ return new URL(String(url)).hostname === "github.com" ? "avatar" : "raw";
504
+ } catch {
505
+ return "raw";
506
+ }
507
+ }
508
+ function tierOrder(preferred) {
509
+ return preferred === "direct" ? ["direct", "weserv"] : ["weserv", "direct"];
510
+ }
511
+ function nextTier(current, preferred = "weserv") {
512
+ const order = tierOrder(preferred);
513
+ const idx = order.indexOf(current);
514
+ if (idx === -1 || idx === order.length - 1) return "failed";
515
+ return order[idx + 1];
516
+ }
517
+ function needsTimeout(tier) {
518
+ return tier === "weserv";
519
+ }
520
+ function preferredTier(bucket) {
521
+ return preferredByBucket[bucket === "avatar" ? "avatar" : "raw"];
522
+ }
523
+ function rememberSuccess(bucket, tier) {
524
+ if (tier !== "weserv" && tier !== "direct") return;
525
+ preferredByBucket[bucket === "avatar" ? "avatar" : "raw"] = tier;
526
+ }
527
+ function resetTierPreferences() {
528
+ preferredByBucket.raw = "weserv";
529
+ preferredByBucket.avatar = "weserv";
530
+ }
531
+ var WESERV_BASE, WESERV_TIMEOUT_MS, preferredByBucket;
532
+ var init_img_chain = __esm({
533
+ "src/client/img-chain.js"() {
534
+ "use strict";
535
+ WESERV_BASE = "https://images.weserv.nl/?";
536
+ WESERV_TIMEOUT_MS = 8e3;
537
+ preferredByBucket = { raw: "weserv", avatar: "weserv" };
538
+ }
539
+ });
540
+
481
541
  // src/client/markdown.js
482
542
  var markdown_exports = {};
483
543
  __export(markdown_exports, {
@@ -1592,6 +1652,7 @@ var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZE
1592
1652
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1593
1653
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1594
1654
  var { lbStep: lbStep2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
1655
+ var { WESERV_TIMEOUT_MS: WESERV_TIMEOUT_MS2, weservUrl: weservUrl2, serviceBucketOf: serviceBucketOf2, tierOrder: tierOrder2, nextTier: nextTier2, needsTimeout: needsTimeout2, preferredTier: preferredTier2, rememberSuccess: rememberSuccess2 } = (init_img_chain(), __toCommonJS(img_chain_exports));
1595
1656
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1596
1657
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
1597
1658
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
@@ -1772,6 +1833,9 @@ var ZH = {
1772
1833
  "lb.prev": "\u4E0A\u4E00\u5F20\uFF08\u2190\uFF09",
1773
1834
  "lb.next": "\u4E0B\u4E00\u5F20\uFF08\u2192\uFF09",
1774
1835
  "lb.hint": "\u70B9\u56FE\u6216\u7A7A\u767D\u5904\u5173\u95ED \xB7 \u2190\u2192 \u5207\u6362",
1836
+ "lb.fail": "\u56FE\u7247\u52A0\u8F7D\u5931\u8D25",
1837
+ "lb.retry": "\u91CD\u8BD5",
1838
+ "lb.open": "\u6253\u5F00\u539F\u56FE",
1775
1839
  "filter.title": "\u7B5B\u9009",
1776
1840
  "filter.sortfield": "\u6392\u5E8F\u5B57\u6BB5",
1777
1841
  "filter.sortdir": "\u6392\u5217\u65B9\u5411",
@@ -2050,6 +2114,9 @@ var EN = {
2050
2114
  "lb.prev": "Previous (\u2190)",
2051
2115
  "lb.next": "Next (\u2192)",
2052
2116
  "lb.hint": "Click image or backdrop to close \xB7 \u2190\u2192 to switch",
2117
+ "lb.fail": "Failed to load image",
2118
+ "lb.retry": "Retry",
2119
+ "lb.open": "Open original",
2053
2120
  "filter.title": "Filter",
2054
2121
  "filter.sortfield": "Sort by",
2055
2122
  "filter.sortdir": "Direction",
@@ -2290,21 +2357,48 @@ var CSS = `
2290
2357
  \u8BC4\u5BA1 R1\uFF1A\u2715/\u2039\u203A \u7528 .dsvm-btn.dsvm-lb* \u590D\u5408\u9009\u62E9\u5668\u2014\u2014\u57FA\u7840 .dsvm-btn \u89C4\u5219\u6E90\u987A\u5E8F\u9760\u540E
2291
2358
  \uFF08padding:5px 14px/font-size:13px/radius:8px\uFF09\uFF0C\u540C\u7279\u5F02\u6027\u4E0B\u4F1A\u53CD\u6740\u5355\u7C7B\u89C4\u5219\uFF08\u5B9E\u6D4B\u5B57\u5F62 13px\u226026px\uFF09\u3002
2292
2359
  \u8BC4\u5BA1 R2\uFF1Atop \u8BA9\u51FA Windows Desktop \u62D6\u62FD\u5E26 var(--dsh-windows-titlebar-height)\uFF080.9.4 \u5148\u4F8B\uFF0C
2293
- \u58F3\u9876 -webkit-app-region:drag \u541E\u70B9\u51FB\u3001\u65E0\u89C6 z-index\uFF09\uFF1BWeb \u65E0\u6B64\u53D8\u91CF\u56DE\u843D 0px \u884C\u4E3A\u4E0D\u53D8\u3002 */
2294
- .dsvm-lightbox{position:fixed;top:var(--dsh-windows-titlebar-height,0px);left:0;right:0;bottom:0;z-index:2147483200;background:rgba(0,0,0,.88);display:flex;align-items:center;justify-content:center}
2360
+ \u58F3\u9876 -webkit-app-region:drag \u541E\u70B9\u51FB\u3001\u65E0\u89C6 z-index\uFF09\uFF1BWeb \u65E0\u6B64\u53D8\u91CF\u56DE\u843D 0px \u884C\u4E3A\u4E0D\u53D8\u3002
2361
+ 0.9.62 \u5F71\u9662\u906E\u7F69\u81EA\u6301\uFF1A\u706F\u7BB1\u662F\u56FE\u7247\u5F71\u9662\u9762\uFF0C\u300C\u6DF1\u8272\u906E\u7F69 + \u6D45\u8272\u63A7\u4EF6\u300D\u662F\u7EC4\u4EF6\u81EA\u6709\u53EF\u8BFB\u6027
2362
+ \u4E0D\u53D8\u91CF\uFF0C\u4E0D\u968F\u989C\u8272\u6A21\u5F0F/\u76AE\u80A4\u7FFB\u8F6C\u3002\u5B9E\u8BC1\u78B0\u649E\uFF1Adsh-skins ADR-0007 \u6D6E\u5C42\u6BDB\u73BB\u7483\u5E76\u96C6\u6309
2363
+ [role=dialog][aria-modal=true] \u547D\u4E2D\u672C\u6839\uFF08\u6D45\u8272\u6A21\u5F0F 86% \u6696\u767D tint+blur14\uFF09\uFF0C\u767D\u8272\u63A7\u4EF6
2364
+ \u5728\u6D45\u5E95\u4E0A\u4E0D\u53EF\u89C1\uFF08\u7528\u6237\u53CC\u6A21\u5F0F\u5B9E\u62CD\uFF09\u3002\u53CC\u4FA7\u4FEE\u590D\u2014\u2014\u76AE\u80A4\u4FA7 :not(.dsvm-lightbox) \u8C41\u514D
2365
+ \uFF08dsh-skins 1.5.1 \u8D77\uFF09\uFF1B\u672C\u4FA7 background !important \u81EA\u6301\uFF1Amarketplace \u793E\u533A\u76AE\u80A4\u4E0D\u53EF
2366
+ \u679A\u4E3E\uFF0C\u5F71\u9662\u4E0D\u53D8\u91CF\u5FC5\u987B\u7531\u7EC4\u4EF6\u81EA\u5DF1\u515C\u5E95\u3002!important \u6E05\u5355\u5B9A\u957F\uFF08\u6CE8\u91CA\u63D0\u53CA\xD72 + \u672C\u89C4\u5219
2367
+ + sidebar footer \u517C\u5BB9\u89C4\u5219\uFF09\uFF0C\u65B0\u589E\u987B\u6539 client-lightbox \u65AD\u8A00\u663E\u5F0F\u767B\u8BB0\uFF0C\u6CE8\u91CA\u5373\u5B88\u536B\u3002 */
2368
+ .dsvm-lightbox{position:fixed;top:var(--dsh-windows-titlebar-height,0px);left:0;right:0;bottom:0;z-index:2147483200;background:rgba(0,0,0,.88)!important;display:flex;align-items:center;justify-content:center}
2295
2369
  .dsvm-lightbox img{max-width:min(92vw,1500px);max-height:74vh;border-radius:8px;cursor:zoom-out;box-shadow:0 10px 44px rgba(0,0,0,.55);display:block}
2296
2370
  .dsvm-btn:focus-visible{outline:2px solid rgba(255,255,255,.75);outline-offset:2px}
2297
2371
  .dsvm-btn.dsvm-lbclose{position:absolute;top:12px;right:12px;width:38px;height:38px;padding:0;border-radius:10px;display:flex;align-items:center;justify-content:center}
2298
2372
  .dsvm-btn.dsvm-lbarrow{position:absolute;top:50%;transform:translateY(-50%);width:46px;height:60px;padding:0;font-size:26px;line-height:1;border-radius:10px}
2299
2373
  .dsvm-lbarrow.prev{left:12px}
2300
2374
  .dsvm-lbarrow.next{right:12px}
2375
+ /* 0.9.62 \u63A7\u4EF6\u6DF1\u8272\u73BB\u7483\u82AF\u7247\uFF1A\u2039\u203A/\u2715 \u539F\u968F\u57FA\u7840 .dsvm-btn \u88F8\u900F\u660E\u767D\uFF08rgba(255,255,255,.14) \u5E95
2376
+ + \u767D\u5B57\u767D\u8FB9\uFF09\uFF0C\u906E\u7F69\u4E00\u65E6\u88AB\u4EFB\u4F55\u6765\u6E90\u6539\u6D45\u5373\u6574\u4F53\u9690\u5F62\uFF08\u672C\u6B21\u4E8B\u6545\u7684\u53E6\u4E00\u534A\uFF09\u3002\u6539\u7528\u4E0E\u5E95\u90E8
2377
+ pill \u540C\u65CF\u7684\u6DF1\u8272\u82AF\u7247\uFF08slate-900 55% + \u767D\u63CF\u8FB9 + blur + \u6295\u5F71\uFF09\u2014\u2014\u6DF1\u906E\u7F69\u4E0A\u9760\u63CF\u8FB9/blur/\u6295\u5F71
2378
+ \u51FA\u5C42\u6B21\uFF0C\u6D45\u906E\u7F69\u4E0A\u82AF\u7247\u672C\u8EAB\u5C31\u662F\u5BF9\u6BD4\u9762\uFF0C\u4EFB\u4F55\u989C\u8272\u6A21\u5F0F\u4E0E\u76AE\u80A4\u4E0B\u90FD\u53EF\u8BFB\uFF1Bhover \u63D0\u4EAE\u3002
2379
+ \u663E\u5F0F :hover\uFF08\u7279\u5F02\u6027 0,3,0\uFF09\u538B\u8FC7\u57FA\u7840 .dsvm-btn:hover\uFF080,2,0\uFF09\u2014\u2014\u5373\u4FBF\u672C\u5757\u6392\u5728\u57FA\u7840\u89C4\u5219
2380
+ \u4E4B\u524D\u4E5F\u4E0D\u88AB\u6E90\u987A\u5E8F\u53CD\u6740\uFF08\u8BC4\u5BA1 R1 \u540C\u65CF\u6559\u8BAD\uFF09\u3002 */
2381
+ .dsvm-btn.dsvm-lbclose,.dsvm-btn.dsvm-lbarrow{background:rgba(15,23,42,.55);border-color:rgba(255,255,255,.28);box-shadow:0 4px 16px rgba(0,0,0,.35);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
2382
+ .dsvm-btn.dsvm-lbclose:hover,.dsvm-btn.dsvm-lbarrow:hover{background:rgba(15,23,42,.8);border-color:rgba(255,255,255,.5)}
2301
2383
  .dsvm-lbbar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:94vw;background:rgba(15,23,42,.62);border:1px solid rgba(255,255,255,.16);border-radius:999px;padding:7px 16px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
2302
2384
  .dsvm-lbcount{color:rgba(255,255,255,.82);font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}
2303
2385
  .dsvm-lbhint{color:rgba(255,255,255,.55);font-size:11px;white-space:nowrap}
2304
2386
  .dsvm-lbdots{display:flex;gap:2px}
2305
2387
  .dsvm-lbdot{box-sizing:content-box;width:8px;height:8px;padding:5px;border-radius:50%;background:rgba(255,255,255,.32);background-clip:content-box;cursor:pointer}
2306
2388
  .dsvm-lbdot.on{background:#fff;background-clip:content-box}
2389
+ /* 0.9.62 \u52A0\u8F7D\u6307\u793A\u4E0E\u6DE1\u5165\uFF1A\u706F\u7BB1\u8D70\u94FE\u671F\u95F4\uFF08weserv \u6162\u901F/8s \u5B88\u536B\u6362\u5C42/\u76F4\u8FDE\u5927\u56FE\uFF09\u6B64\u524D\u662F
2390
+ \u7A7A\u9ED1\u9ED1\u6D1E\uFF0C\u73B0\u8F6C\u5708\u5360\u4F4D\uFF1B\u56FE\u7247 onLoad\uFF08useImgChain settled\uFF09\u540E 160ms \u6DE1\u5165\u6362\u573A\u3002
2391
+ prefers-reduced-motion \u4E0B\u8F6C\u573A\u505C\u7528\uFF08\u8F6C\u5708\u964D\u901F\u4FDD\u7559\u6D3B\u52A8\u6307\u793A\uFF09\u3002 */
2392
+ @keyframes dsvm-lbspin{to{transform:rotate(360deg)}}
2393
+ .dsvm-lbspin{position:absolute;left:50%;top:50%;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:3px solid rgba(255,255,255,.25);border-top-color:rgba(255,255,255,.92);animation:dsvm-lbspin .8s linear infinite}
2394
+ .dsvm-lightbox .dsvm-lbimg{opacity:0;transition:opacity .16s ease-out}
2395
+ .dsvm-lightbox .dsvm-lbimg.dsvm-lbimg-on{opacity:1}
2396
+ @media (prefers-reduced-motion:reduce){.dsvm-lbspin{animation-duration:1.6s}.dsvm-lightbox .dsvm-lbimg{transition:none}}
2307
2397
  @media (max-width:640px){.dsvm-lbhint{display:none}.dsvm-btn.dsvm-lbarrow{width:40px;height:54px}}
2398
+ /* 0.9.61 \u53CC\u8D25\u7EC8\u6001\u5360\u4F4D\uFF1A\u5C45\u4E2D\u5217\u2014\u2014\u26A0 \u6587\u6848 + \u91CD\u8BD5\uFF08\u590D\u7528 dsvm-btn\uFF09+ \u6253\u5F00\u539F\u56FE\uFF08ExtLink \u65B0\u6807\u7B7E\uFF09 */
2399
+ .dsvm-lbfail{display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 32px;background:rgba(15,23,42,.55);border:1px solid rgba(255,255,255,.14);border-radius:12px}
2400
+ .dsvm-lbfailmsg{color:rgba(255,255,255,.85);font-size:14px}
2401
+ .dsvm-lbfail a{color:rgba(255,255,255,.75);font-size:13px;text-decoration:underline}
2308
2402
  .dsvm-card-clickable, .dshm-card{cursor:pointer}
2309
2403
  .dshm-card:hover{border-color:var(--dsw-alias-interactive-bg-selected,#4f46e5)}
2310
2404
  .dsvm-btn{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.14);color:#fff;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;cursor:pointer}
@@ -2563,18 +2657,20 @@ function useMarketData(zone = "community") {
2563
2657
  return { query, data, loading, error, reload, updateQuery };
2564
2658
  }
2565
2659
  function Icon({ entry }) {
2566
- const [broken, setBroken] = useState(false);
2567
2660
  const letter = String(entry.name || entry.id || "?").charAt(0).toUpperCase();
2568
2661
  const url = entry.icon || (entry.github ? `https://github.com/${entry.github.split("/")[0]}.png?size=64` : null);
2569
- if (!url || broken) {
2662
+ const chain = useImgChain(url || "", { h: 96 });
2663
+ if (!url || chain.failed) {
2570
2664
  return h("div", { className: "dshm-icon", "aria-hidden": "true" }, letter);
2571
2665
  }
2572
2666
  return h("img", {
2573
2667
  className: "dshm-icon",
2574
- src: url,
2668
+ src: chain.src,
2575
2669
  alt: "",
2576
- onError: () => setBroken(true),
2577
- referrerPolicy: "no-referrer"
2670
+ onError: chain.onError,
2671
+ onLoad: chain.onLoad,
2672
+ referrerPolicy: "no-referrer",
2673
+ decoding: "async"
2578
2674
  });
2579
2675
  }
2580
2676
  function Spin() {
@@ -2962,7 +3058,7 @@ function compactCount(n) {
2962
3058
  }
2963
3059
  return String(n);
2964
3060
  }
2965
- function Shot({ src, onClick }) {
3061
+ function Shot({ src, onClick, onBroken }) {
2966
3062
  const [show, setShow] = useState(false);
2967
3063
  const ref = useRef(null);
2968
3064
  useEffect(() => {
@@ -2983,6 +3079,10 @@ function Shot({ src, onClick }) {
2983
3079
  io.observe(el);
2984
3080
  return () => io.disconnect();
2985
3081
  }, []);
3082
+ const chain = useImgChain(src, { h: 300, active: show });
3083
+ useEffect(() => {
3084
+ if (chain.failed && onBroken) onBroken(src);
3085
+ }, [chain.failed, onBroken, src]);
2986
3086
  return h(
2987
3087
  "div",
2988
3088
  {
@@ -2999,9 +3099,52 @@ function Shot({ src, onClick }) {
2999
3099
  }
3000
3100
  }
3001
3101
  },
3002
- show ? h("img", { className: "dsvm-shot", src, alt: "", loading: "lazy", referrerPolicy: "no-referrer", fetchPriority: "low" }) : null
3102
+ show ? chain.failed ? null : h("img", { className: "dsvm-shot", src: chain.src, alt: "", loading: "lazy", referrerPolicy: "no-referrer", fetchPriority: "low", decoding: "async", onError: chain.onError, onLoad: chain.onLoad }) : null
3003
3103
  );
3004
3104
  }
3105
+ function useImgChain(url, opts) {
3106
+ const { w, h: h2, active = true } = opts || {};
3107
+ const bucket = useMemo(() => serviceBucketOf2(url), [url]);
3108
+ const startPrefRef = useRef(null);
3109
+ if (startPrefRef.current === null) startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3110
+ const [tier, setTier] = useState(() => tierOrder2(startPrefRef.current)[0]);
3111
+ const [settled, setSettled] = useState(false);
3112
+ const startChain = useCallback(() => {
3113
+ startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3114
+ setSettled(false);
3115
+ setTier(tierOrder2(startPrefRef.current)[0]);
3116
+ }, [url]);
3117
+ const [prevUrl, setPrevUrl] = useState(url);
3118
+ if (prevUrl !== url) {
3119
+ setPrevUrl(url);
3120
+ startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3121
+ setSettled(false);
3122
+ setTier(tierOrder2(startPrefRef.current)[0]);
3123
+ }
3124
+ const failed = tier === "failed";
3125
+ const src = failed ? "" : tier === "weserv" ? weservUrl2(url, { w, h: h2 }) : url;
3126
+ const advance = () => {
3127
+ setSettled(false);
3128
+ setTier((t) => nextTier2(t, startPrefRef.current));
3129
+ };
3130
+ const onError = advance;
3131
+ const onLoad = useCallback(() => {
3132
+ setSettled(true);
3133
+ rememberSuccess2(bucket, tier);
3134
+ }, [bucket, tier]);
3135
+ const retry = startChain;
3136
+ useEffect(() => {
3137
+ if (!url || !active || settled || !needsTimeout2(tier)) return;
3138
+ const timer = setTimeout(advance, WESERV_TIMEOUT_MS2);
3139
+ return () => clearTimeout(timer);
3140
+ }, [url, active, settled, tier]);
3141
+ return { src, failed, retry, onError, onLoad, settled };
3142
+ }
3143
+ function UseImgChainProbe(props) {
3144
+ const chain = useImgChain(props.url, { w: props.w, h: props.h });
3145
+ if (chain.failed) return h("div", { className: "dsvm-probe-failed" }, "chain:failed");
3146
+ return h("img", { src: chain.src, alt: "", referrerPolicy: "no-referrer", decoding: "async", onError: chain.onError, onLoad: chain.onLoad });
3147
+ }
3005
3148
  function Lightbox({ shots, index, onNav, onClose }) {
3006
3149
  const single = shots.length <= 1;
3007
3150
  const closeRef = useRef(null);
@@ -3016,10 +3159,21 @@ function Lightbox({ shots, index, onNav, onClose }) {
3016
3159
  useEffect(() => {
3017
3160
  if (closeRef.current) closeRef.current.focus();
3018
3161
  }, []);
3162
+ const chain = useImgChain(shots[index], { w: 1600 });
3019
3163
  const node = h(
3020
3164
  "div",
3021
3165
  { className: "dsvm-lightbox", role: "dialog", "aria-modal": "true", "aria-label": lookup("lb.title"), ...backdropCloseHandlers2(onClose) },
3022
- h("img", { src: shots[index], alt: "", onClick: onClose, referrerPolicy: "no-referrer" }),
3166
+ chain.failed ? h(
3167
+ "div",
3168
+ { className: "dsvm-lbfail", onClick: (e) => e.stopPropagation() },
3169
+ h("div", { className: "dsvm-lbfailmsg" }, `\u26A0 ${lookup("lb.fail")}`),
3170
+ h("button", { className: "dsvm-btn", onClick: chain.retry }, lookup("lb.retry")),
3171
+ h(ExtLink, { href: shots[index] }, lookup("lb.open"))
3172
+ ) : [
3173
+ // 0.9.62 走链期间转圈占位(settled 即卸载;点它视为点遮罩可关——急躁关闭是合理逃生门)
3174
+ !chain.settled && chain.src ? h("div", { className: "dsvm-lbspin", "aria-hidden": "true" }) : null,
3175
+ h("img", { className: `dsvm-lbimg${chain.settled ? " dsvm-lbimg-on" : ""}`, src: chain.src, alt: "", onClick: onClose, referrerPolicy: "no-referrer", decoding: "async", onError: chain.onError, onLoad: chain.onLoad })
3176
+ ],
3023
3177
  h("button", { ref: closeRef, className: "dsvm-btn dsvm-lbclose", "aria-label": lookup("common.close"), title: lookup("common.close"), onClick: onClose }, h(XIcon)),
3024
3178
  single ? null : h("button", { className: "dsvm-btn dsvm-lbarrow prev", "aria-label": lookup("lb.prev"), title: lookup("lb.prev"), onClick: () => onNav(lbStep2(index, -1, shots.length)) }, "\u2039"),
3025
3179
  single ? null : h("button", { className: "dsvm-btn dsvm-lbarrow next", "aria-label": lookup("lb.next"), title: lookup("lb.next"), onClick: () => onNav(lbStep2(index, 1, shots.length)) }, "\u203A"),
@@ -3046,7 +3200,39 @@ function useModalDepth(active) {
3046
3200
  }
3047
3201
  function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeBusy, upgradeRec, profileKind, installRec, installNote }) {
3048
3202
  useModalDepth(true);
3049
- const shots = it.community === true ? safeScreenshots(it) : [];
3203
+ const communityScreenshotSource = it.community === true || typeof it.owner === "string";
3204
+ const [primaryShotState, setPrimaryShotState] = useState({ id: null, screenshots: [] });
3205
+ useEffect(() => {
3206
+ let live = true;
3207
+ const controller = new AbortController();
3208
+ setPrimaryShotState({ id: it.id, screenshots: [] });
3209
+ if (communityScreenshotSource || typeof it.id !== "string" || it.id.trim() === "") {
3210
+ return () => {
3211
+ live = false;
3212
+ controller.abort();
3213
+ };
3214
+ }
3215
+ api("primary-screenshots", { id: it.id }, controller.signal).then((data) => {
3216
+ if (!live) return;
3217
+ setPrimaryShotState({ id: it.id, screenshots: safeScreenshots({ screenshots: data && data.screenshots }) });
3218
+ }).catch(() => {
3219
+ if (live) setPrimaryShotState({ id: it.id, screenshots: [] });
3220
+ });
3221
+ return () => {
3222
+ live = false;
3223
+ controller.abort();
3224
+ };
3225
+ }, [it.id, it.github, it.community, it.owner]);
3226
+ const primaryShots = primaryShotState.id === it.id ? primaryShotState.screenshots : [];
3227
+ const shots = communityScreenshotSource ? safeScreenshots(it) : primaryShots;
3228
+ const [brokenShots, setBrokenShots] = useState([]);
3229
+ const [brokenShotsKey, setBrokenShotsKey] = useState(it.id);
3230
+ if (brokenShotsKey !== it.id) {
3231
+ setBrokenShotsKey(it.id);
3232
+ setBrokenShots([]);
3233
+ }
3234
+ const breakShot = useCallback((src) => setBrokenShots((prev) => prev.includes(src) ? prev : prev.concat(src)), []);
3235
+ const visible = shots.filter((src) => !brokenShots.includes(src));
3050
3236
  const [lb, setLb] = useState(null);
3051
3237
  const [copied, setCopied] = useState(false);
3052
3238
  const [rmOpen, setRmOpen] = useState(false);
@@ -3060,14 +3246,14 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3060
3246
  if (lb !== null) setLb(null);
3061
3247
  else onClose();
3062
3248
  }
3063
- if (lb !== null && shots.length > 1) {
3064
- if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, shots.length));
3065
- if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, shots.length));
3249
+ if (lb !== null && visible.length > 1) {
3250
+ if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, visible.length));
3251
+ if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, visible.length));
3066
3252
  }
3067
3253
  };
3068
3254
  document.addEventListener("keydown", onKey);
3069
3255
  return () => document.removeEventListener("keydown", onKey);
3070
- }, [lb, shots.length, onClose]);
3256
+ }, [lb, visible.length, onClose]);
3071
3257
  const catLabel = labels && labels[it.category] || (["essentials", "cui-picks", "self-dev", "tencent-lighthouse", "watchlist"].includes(it.category) ? lookup("cat." + it.category) : it.category);
3072
3258
  const installCmd = typeof it.install === "string" && it.install !== "" ? it.install : it.source === "npm" ? `dsh plugin --profile web add ${it.npm}` : `dsh plugin --profile web add github:${it.github}`;
3073
3259
  const copyCmd = async () => {
@@ -3136,10 +3322,10 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3136
3322
  h("summary", null, lookup("readme.show")),
3137
3323
  rmOpen ? h(ReadmeBlock, { pkg: it.npm, repo: it.github }) : h("div", { className: "dshm-hint" }, lookup("readme.loading"))
3138
3324
  ) : null,
3139
- shots.length ? h(
3325
+ visible.length ? h(
3140
3326
  "div",
3141
3327
  { className: "dsvm-shotrow" },
3142
- ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(i) }))
3328
+ ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(visible.indexOf(src)), onBroken: breakShot }))
3143
3329
  ) : null,
3144
3330
  it.community === true ? h(
3145
3331
  "details",
@@ -3182,7 +3368,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3182
3368
  ) : h("button", { className: "dshm-btn primary", disabled: busy, onClick: () => onInstall(it) }, busy ? h(Spin) : lookup("action.install"))
3183
3369
  )
3184
3370
  ),
3185
- lb !== null && shots.length ? h(Lightbox, { shots, index: lb, onNav: setLb, onClose: () => setLb(null) }) : null
3371
+ // 0.9.61(评审 F):visible 活引用 + index 钳制(后台剔除收缩防越界)
3372
+ lb !== null && visible.length ? h(Lightbox, { shots: visible, index: Math.min(lb, visible.length - 1), onNav: setLb, onClose: () => setLb(null) }) : null
3186
3373
  );
3187
3374
  }
3188
3375
  var ZONE_TABS = [
@@ -13,6 +13,7 @@ import { runDoctor } from './doctor.js';
13
13
  import { listInstalledWithMeta, listMarket, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
14
14
  import { runProfileTransaction, TransactionError } from './profile-transaction.js';
15
15
  import { readInstalledPluginReadme } from './installed.js';
16
+ import { loadPrimaryScreenshots } from './primary-screenshots.js';
16
17
  import { isNewerVersion, npmLatest, npmPackumentReadme } from './versions.js';
17
18
  import { getCommunitySummary } from './community.js';
18
19
  import { RegistryConfigError } from './registry-controller.js';
@@ -218,6 +219,7 @@ export function createApiDispatcher(ctx) {
218
219
  npmLatest: ctx.deps?.npmLatest ?? npmLatest,
219
220
  readInstalledPluginReadme: ctx.deps?.readInstalledPluginReadme ?? readInstalledPluginReadme,
220
221
  npmPackumentReadme: ctx.deps?.npmPackumentReadme ?? npmPackumentReadme,
222
+ loadPrimaryScreenshots: ctx.deps?.loadPrimaryScreenshots ?? loadPrimaryScreenshots,
221
223
  getCommunitySummary: ctx.deps?.getCommunitySummary ?? getCommunitySummary,
222
224
  runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
223
225
  scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
@@ -324,6 +326,28 @@ export function createApiDispatcher(ctx) {
324
326
  payload = { plugins: snap.loaded.registry.plugins, registryState: snap.loaded, community };
325
327
  break;
326
328
  }
329
+ case 'primary-screenshots': {
330
+ const id = strArg(body, 'id');
331
+ if (!id)
332
+ throw new ApiProtocolError(400, '缺少 id');
333
+ await ctx.controller.ensureReady({ signal });
334
+ const snap = await ctx.controller.snapshot({ signal });
335
+ // 只信 active Primary Registry 的精确 id → github 映射;请求体中的 github/url 一律忽略。
336
+ // 本 method 不调用 listMarket/getCommunitySummary,也不加载 Community Catalog。
337
+ const entry = snap.loaded.registry.plugins.find((candidate) => candidate.id === id);
338
+ if (!entry || typeof entry.github !== 'string' || entry.github.trim() === '') {
339
+ payload = { screenshots: [] };
340
+ break;
341
+ }
342
+ try {
343
+ payload = { screenshots: await d.loadPrimaryScreenshots(entry.github) };
344
+ }
345
+ catch {
346
+ // 截图是可选详情元数据;manifest 不可用不应阻塞详情或安装决策。
347
+ payload = { screenshots: [] };
348
+ }
349
+ break;
350
+ }
327
351
  case 'market': {
328
352
  await ctx.controller.ensureReady();
329
353
  // GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..96、缺省 32
@@ -0,0 +1,142 @@
1
+ import { fetchTextLimited } from './httpx.js';
2
+ const MANIFEST_HOST = 'raw.githubusercontent.com';
3
+ const MANIFEST_FILE = 'screenshots.json';
4
+ const MAX_MANIFEST_BYTES = 64 * 1024;
5
+ const MAX_SCREENSHOTS = 8;
6
+ const SUCCESS_TTL_MS = 10 * 60_000;
7
+ const EMPTY_TTL_MS = 60_000;
8
+ const OWNER_MAX = 39;
9
+ const REPO_MAX = 100;
10
+ const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
11
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
12
+ function manifestUrl(github) {
13
+ const value = typeof github === 'string' ? github.trim() : '';
14
+ if (!GITHUB_REPO_RE.test(value))
15
+ return null;
16
+ const slash = value.indexOf('/');
17
+ const owner = value.slice(0, slash);
18
+ const repo = value.slice(slash + 1);
19
+ if (owner.length > OWNER_MAX || repo.length > REPO_MAX || repo === '.' || repo === '..')
20
+ return null;
21
+ return `https://${MANIFEST_HOST}/${owner}/${repo}/HEAD/${MANIFEST_FILE}`;
22
+ }
23
+ function screenshotUrl(manifest, rawPath) {
24
+ if (typeof rawPath !== 'string')
25
+ return null;
26
+ const path = rawPath.trim();
27
+ if (!path || CONTROL_RE.test(path))
28
+ return null;
29
+ if (path.startsWith('/') || path.startsWith('\\') || path.includes('\\'))
30
+ return null;
31
+ if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith('//'))
32
+ return null;
33
+ if (/[?#]/.test(path))
34
+ return null;
35
+ const segments = path.split('/');
36
+ if (segments.some((part) => part === '' || part === '.' || part === '..'))
37
+ return null;
38
+ const base = new URL('.', manifest);
39
+ return new URL(segments.map((part) => encodeURIComponent(part)).join('/'), base).toString();
40
+ }
41
+ function parseManifest(text, manifestUrl) {
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > MAX_SCREENSHOTS)
50
+ return [];
51
+ const result = [];
52
+ for (const path of parsed) {
53
+ const url = screenshotUrl(manifestUrl, path);
54
+ if (!url)
55
+ return [];
56
+ result.push(url);
57
+ }
58
+ return result;
59
+ }
60
+ function assertManifestHost(raw, isRedirectHop) {
61
+ let url;
62
+ try {
63
+ url = new URL(raw);
64
+ }
65
+ catch {
66
+ throw new Error(isRedirectHop ? 'screenshot manifest redirect URL is invalid' : 'invalid screenshot manifest request URL');
67
+ }
68
+ if (url.protocol !== 'https:' || url.hostname !== MANIFEST_HOST || url.username || url.password || url.port) {
69
+ throw new Error(isRedirectHop
70
+ ? 'screenshot manifest redirects must remain on raw.githubusercontent.com'
71
+ : 'screenshot manifest request URL must be https://raw.githubusercontent.com');
72
+ }
73
+ }
74
+ /**
75
+ * Read an author-owned root screenshots.json without consulting Community Catalog.
76
+ * All network access is bounded, and a rejected/empty manifest degrades to no gallery.
77
+ */
78
+ export function createPrimaryScreenshotReader(options = {}) {
79
+ const fetchText = options.fetchText ?? fetchTextLimited;
80
+ const now = options.now ?? Date.now;
81
+ const cache = new Map();
82
+ const flights = new Map();
83
+ return async (github) => {
84
+ const url = manifestUrl(github);
85
+ if (!url)
86
+ return [];
87
+ const key = url.toLowerCase();
88
+ const timestamp = now();
89
+ for (const [cachedKey, value] of cache) {
90
+ if (value.expiresAt <= timestamp)
91
+ cache.delete(cachedKey);
92
+ }
93
+ const cached = cache.get(key);
94
+ if (cached && cached.expiresAt > timestamp)
95
+ return [...cached.screenshots];
96
+ const pending = flights.get(key);
97
+ if (pending)
98
+ return [...(await pending)];
99
+ const flight = (async () => {
100
+ let screenshots = [];
101
+ let hop = 0;
102
+ try {
103
+ const text = await fetchText(url, {
104
+ timeoutMs: 5_000,
105
+ maxBytes: MAX_MANIFEST_BYTES,
106
+ headers: { accept: 'application/json' },
107
+ onRequest: (hopUrl) => {
108
+ // 逐跳 host guard;错误文案区分初始跳与重定向跳,避免调试误导(评审建议落盘)。
109
+ const isRedirectHop = hop > 0;
110
+ hop += 1;
111
+ assertManifestHost(hopUrl, isRedirectHop);
112
+ },
113
+ });
114
+ screenshots = parseManifest(text, url);
115
+ }
116
+ catch {
117
+ // Images are optional metadata: a missing/unavailable manifest must not block detail/install.
118
+ screenshots = [];
119
+ }
120
+ cache.set(key, {
121
+ screenshots,
122
+ expiresAt: now() + (screenshots.length > 0 ? SUCCESS_TTL_MS : EMPTY_TTL_MS),
123
+ });
124
+ return screenshots;
125
+ })();
126
+ flights.set(key, flight);
127
+ try {
128
+ return [...(await flight)];
129
+ }
130
+ finally {
131
+ if (flights.get(key) === flight)
132
+ flights.delete(key);
133
+ }
134
+ };
135
+ }
136
+ /** Production reader: one process-memory cache shared by GUI Host calls. */
137
+ export const loadPrimaryScreenshots = createPrimaryScreenshotReader();
138
+ /** Exported parser contract for focused regression tests; production calls the reader above. */
139
+ export const PRIMARY_SCREENSHOT_LIMIT = MAX_SCREENSHOTS;
140
+ export const PRIMARY_SCREENSHOT_MANIFEST_MAX_BYTES = MAX_MANIFEST_BYTES;
141
+ export const PRIMARY_SCREENSHOT_SUCCESS_TTL_MS = SUCCESS_TTL_MS;
142
+ export const PRIMARY_SCREENSHOT_EMPTY_TTL_MS = EMPTY_TTL_MS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.60",
3
+ "version": "0.9.62",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",