dsh-web-icon-indicator 0.5.1 → 0.5.2

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
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.1...v0.5.2) (2026-09-24)
11
+
12
+ ### Added
13
+
14
+ * give the official Plugins page its own artwork ([332896b](https://github.com/waknow/dsh-web-icon-indicator/commit/332896b4b05af94877e63a33ad9c4895b94ad7dd))
15
+
16
+ ### Fixed
17
+
18
+ * repaint the favicon on DSH 0.1.7's theme-scoped link pair ([a57f78f](https://github.com/waknow/dsh-web-icon-indicator/commit/a57f78ff98acd4d9ae41c03c3b0519113e357bb2))
19
+
10
20
  ## [0.5.1](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.0...v0.5.1) (2026-09-22)
11
21
 
12
22
  ### Fixed
package/README.md CHANGED
@@ -34,6 +34,8 @@ Browser tab favicon reflects the current DSH session state — `idle` / `running
34
34
 
35
35
  Changes are saved through the settings transport into the profile patch and applied to the running tab within ~1 s — no reload, no restart. See [Configure](#configure) for the full key reference.
36
36
 
37
+ > 🐳 **The Plugins page shows this plugin's own artwork** — on DSH ≥ 0.1.7 the **Plugins / 插件** page draws a 48 px icon on every bundle card (a 40 px one on every row) from `package.json` → `icon`. This repo declares `"icon": "icons/plugin.svg"`, so the card shows the whale instead of the generic placeholder. The icon is read by the host (`readPluginMeta` → `iconOf`) and shipped to the browser as a base64 `data:` URL; **a missing or malformed icon never blanks the card — it turns the plugin's `meta.error` on and the card shows a problem tag**, so keep the path relative, inside the package, and ≤ 256 KiB. Adding `icon` needs no settings, no code, and no host restart — only a new install or publish; see [Plugin artwork on the Plugins page](#plugin-artwork-on-the-plugins-page).
38
+
37
39
  ## 🎬 Default configuration, visualized
38
40
 
39
41
  The four default states, exactly as they appear in the browser tab (the `asking` whale really blinks):
@@ -115,6 +117,11 @@ dsh plugin --profile web add <path-or-tarball>
115
117
 
116
118
  Or drop the directory into `~/.dsh/profiles/web/node_modules/<name>/` and ship a `cordis.patch.yml` that matches the one shipped here.
117
119
 
120
+ > ℹ️ The official **Plugins** page reads the artwork from **`package.json.icon` of the
121
+ > installed package**, so it appears only after an install (or a publish) — a page
122
+ > that was already open keeps its last snapshot until you reload it. See
123
+ > [Plugin artwork on the Plugins page](#plugin-artwork-on-the-plugins-page).
124
+
118
125
  ## Configure
119
126
 
120
127
  All keys are optional; defaults shown. `statusPath` and `iconPathPrefix` are
@@ -133,6 +140,10 @@ they are intentionally **not** part of the settings page's live form (they are
133
140
  | `defaultColor` | *(unset)* | Default icon color (the idle whale's primary) — tell multiple DSH instances apart. Warns when it is too close to another state's color |
134
141
  | `states` | see below | Per-state visual config |
135
142
 
143
+ > The Plugins-page artwork is **not** a config key — it comes from
144
+ > `package.json.icon` (see [Plugin artwork on the Plugins page](#plugin-artwork-on-the-plugins-page)).
145
+ > `iconsDir` only tells the *favicon* routes where `base.svg` lives.
146
+
136
147
  Each entry in `states` is one object per state: `{ effect, colors[], speed? }`:
137
148
 
138
149
  ```yaml
@@ -271,6 +282,48 @@ resolving:
271
282
  - Deployments without a settings service are unaffected: the plugin keeps
272
283
  running on the composition entry + schema defaults it was mounted with.
273
284
 
285
+ ## Plugin artwork on the Plugins page
286
+
287
+ DSH ≥ 0.1.7 renders a **per-plugin icon on the official Plugins page**: 48 px on
288
+ each bundle card, 40 px on each row (`PackageArtwork` in
289
+ `@deepseek-ai/dsh-client-ui-plugin-manager`, which falls back to a generic
290
+ placeholder when a package declares none). The artwork comes straight from the
291
+ package manifest, so shipping one is pure metadata — **no code, no settings, no
292
+ host restart**:
293
+
294
+ ```jsonc
295
+ // package.json
296
+ {
297
+ "name": "dsh-web-icon-indicator",
298
+ "icon": "icons/plugin.svg" // relative to package.json, and inside the package
299
+ }
300
+ ```
301
+
302
+ The host reads it before evaluating any plugin code
303
+ (`readPluginMeta` → `iconOf` in `@deepseek-ai/dsh-app-boot`):
304
+
305
+ | Rule | Value |
306
+ | --- | --- |
307
+ | Field | top-level `package.json.icon` (a sibling of `name`/`version` — **not** under `dsh`) |
308
+ | Path form | relative to the manifest's directory; absolute paths, Windows drive letters (`C:\…`) and URLs are rejected |
309
+ | Containment | must stay inside the manifest directory after `realpath` resolution (`..` escapes are rejected) |
310
+ | Formats | `.svg` → `image/svg+xml`, `.png` → `image/png`, `.jpg` / `.jpeg` → `image/jpeg`, `.webp` → `image/webp` |
311
+ | Size | ≤ 256 KiB (checked both on `stat` and on the bytes actually read) |
312
+ | Delivery | read as bytes and shipped to the browser as a base64 `data:` URL on the wire (`PluginLocalizedMeta.icon`) |
313
+
314
+ > ⚠️ **A bad icon is worse than no icon.** Any violation is a *metadata error*,
315
+ > not a silent fallback: the reader returns `meta.error`, the host surfaces it,
316
+ > and the card shows a **problem tag** for the plugin. Keep the file in the
317
+ > package (`files` ships `icons/`) and make sure it is standalone — a raw
318
+ > `icons/base.svg` would be **invalid**, because its `#p { fill: __COLOR__ }`
319
+ > placeholder is not a colour. This repo therefore ships the pre-filled
320
+ > [`icons/plugin.svg`](./icons/plugin.svg) (`base.svg` with `__COLOR__` →
321
+ > `#1a1a1a`) and asserts the whole contract in `test/verify.js` (F62–F65).
322
+
323
+ Because the icon is read from the **installed** package, a local edit takes
324
+ effect only after reinstalling the plugin (`dsh plugin --profile web add
325
+ <path>`) or publishing a new version.
326
+
274
327
  ## How it works
275
328
 
276
329
  - Host plugin with a small browser half: registers routes on the existing `webServer` — the status JSON endpoint, a static `/dsh-web-icon-indicator/base.svg` (the whale template), and one `tapIndex` that injects a small browser script into every served `index.html`. The config surface is the plugin's exported `Config` schema, which the DSH settings service projects into a live page keyed by the `dsh-web-icon-indicator` entry id (see above).
@@ -293,6 +346,7 @@ The favicon is a plain image, so browsers never run the SVG's own CSS/JS animati
293
346
  Known limitations (current as of Safari 26.3):
294
347
 
295
348
  - **Favicons have their own cache.** Chrome keeps a favicon database, Firefox a `favicons.sqlite`, and **Safari a system-level icon cache** — none of which a normal *clear cache* touches, and WebKit even caches the "no icon" case. That is why a changed icon can linger for an existing tab. The plugin already mitigates this: it serves `base.svg` and the status endpoint with `Cache-Control: no-store`, bundles a freshness query (`?t=Date.now()`) on its fetches, and replaces the `<link rel=icon>` node on each state change.
349
+ - **A theme-scoped favicon pair must be collapsed first (DSH ≥ 0.1.7).** The shell's `index.html` ships *two* `rel=icon` links — `favicon-dark.svg` for `prefers-color-scheme: dark` and `favicon.svg` for light — because up to `0.1.6-alpha.2` there was exactly one. A browser resolves the favicon to the **last connected link whose `media` matches**, so repainting only the first one left the tab showing the shell's icon no matter what the session did. The plugin now normalizes the set to a single always-matching link at startup (keeping the variant your scheme selects) before painting. If you are on a version before 0.5.2 and the tab icon simply never changes, this is why.
296
350
  - **Safari renders SVG favicons but ignores their internal CSS** — no `@media`, no `prefers-color-scheme`, no CSS animation. So all recoloring must be baked into each frame's markup (which the plugin does) rather than driven by CSS variables.
297
351
  - **`data:`-URI SVG favicons are unreliable in Safari** (WebKit bug 236616, still open; reproduced on Safari 17.6). The plugin currently builds each frame as a `data:image/svg+xml` URI, so on Safari the tab icon may not render at all — the biggest known gap.
298
352
  - **Dynamic JS updates in Safari are hit-or-miss;** they may require a reload, and Safari "locks onto" the first icon it sees. There is no guaranteed, spec-supported way to swap a favicon live in Safari today.
package/README.zh.md CHANGED
@@ -34,6 +34,8 @@
34
34
 
35
35
  改动会通过 settings 传输层持久化到 profile patch,约 1 秒内应用到已打开的标签页——无需刷新、无需重启。完整键说明见 [配置](#配置)。
36
36
 
37
+ > 🐳 **插件页会显示本插件自己的图标** —— DSH ≥ 0.1.7 的 **设置 → 插件** 页面会为每个 bundle 卡片画一个 48px 图标(每一行 40px),来源是 `package.json` → `icon`。本仓库声明了 `"icon": "icons/plugin.svg"`,所以卡片显示的是鲸鱼而不是通用占位图。图标由宿主读取(`readPluginMeta` → `iconOf`),并以 base64 `data:` URL 传给浏览器;**图标缺失或不合规不会把卡片变空——而是点亮该插件的 `meta.error`,卡片上出现「问题」标签**,所以路径必须相对、必须在包内、且 ≤ 256 KiB。加这个字段不需要任何配置、代码或宿主重启,只需重新安装或发一个新版本;详见[插件页图标](#插件页图标)。
38
+
37
39
  ## 🎬 默认配置,可视化
38
40
 
39
41
  四个默认状态在浏览器标签页中的实际效果(`asking` 那条鲸鱼真的在闪烁):
@@ -110,6 +112,10 @@ dsh plugin --profile web add <路径或tarball>
110
112
 
111
113
  或将目录放进 `~/.dsh/profiles/web/node_modules/<name>/`,并附带与包内一致的 `cordis.patch.yml`。
112
114
 
115
+ > ℹ️ 官方**插件页**的图标读取的是**已安装包的 `package.json.icon`**,因此只有在安装
116
+ > (或发布新版本)之后才会出现——已经打开的插件页会一直显示上一次的快照,直到你刷新。
117
+ > 详见[插件页图标](#插件页图标)。
118
+
113
119
  ## 配置
114
120
 
115
121
  所有键均可选,默认值如下。`statusPath` 与 `iconPathPrefix` 是**注册期**键:
@@ -126,6 +132,10 @@ dsh plugin --profile web add <路径或tarball>
126
132
  | `defaultColor` | *(未设置)* | 默认图标颜色(待机鲸鱼的主色)—— 用于区分多个 DSH 实例;与其它状态颜色过于接近时会告警 |
127
133
  | `states` | 见下 | 每个状态的视觉配置 |
128
134
 
135
+ > 插件页的图标**不是**配置键——它来自 `package.json.icon`
136
+ > (见[插件页图标](#插件页图标))。`iconsDir` 只告诉 *favicon* 路由去哪里取
137
+ > `base.svg`。
138
+
129
139
  `states` 中每个状态是一个对象:`{ effect, colors[], speed? }`:
130
140
 
131
141
  ```yaml
@@ -236,6 +246,44 @@ schemastery schema),**并在宿主仍提供旧版服务时**用同一份 sch
236
246
  扫描器会在下次启动 profile 时识别新的 `dsh.client` 声明。
237
247
  - 未组合 settings 服务的部署不受影响:插件继续使用挂载时的合成条目 + schema 默认值。
238
248
 
249
+ ## 插件页图标
250
+
251
+ DSH ≥ 0.1.7 会在官方**插件页**为每个插件渲染图标:bundle 卡片 48px、每一行
252
+ 40px(`@deepseek-ai/dsh-client-ui-plugin-manager` 的 `PackageArtwork`,未声明时
253
+ 回退到通用占位图)。图标直接来自包清单,所以给出它纯粹是元数据——**不需要改代码、
254
+ 不需要配置、不需要重启宿主**:
255
+
256
+ ```jsonc
257
+ // package.json
258
+ {
259
+ "name": "dsh-web-icon-indicator",
260
+ "icon": "icons/plugin.svg" // 相对 package.json,且必须留在包内
261
+ }
262
+ ```
263
+
264
+ 宿主在**不执行任何插件代码**的前提下读取它(`@deepseek-ai/dsh-app-boot` 的
265
+ `readPluginMeta` → `iconOf`):
266
+
267
+ | 规则 | 取值 |
268
+ | --- | --- |
269
+ | 字段 | 顶层 `package.json.icon`(与 `name`/`version` 同级,**不是**放在 `dsh` 下) |
270
+ | 路径形式 | 相对清单所在目录;绝对路径、Windows 盘符(`C:\…`)与 URL 都会被拒绝 |
271
+ | 包含关系 | 经 `realpath` 解析后必须仍在清单目录内(`..` 越界会被拒绝) |
272
+ | 格式 | `.svg` → `image/svg+xml`,`.png` → `image/png`,`.jpg` / `.jpeg` → `image/jpeg`,`.webp` → `image/webp` |
273
+ | 大小 | ≤ 256 KiB(`stat` 与实际读到的字节数各校验一次) |
274
+ | 传输 | 读成字节后以 base64 `data:` URL 随元数据下发(`PluginLocalizedMeta.icon`) |
275
+
276
+ > ⚠️ **坏的图标比没有图标更糟。** 任何不合规都不是静默回退,而是**元数据错误**:
277
+ > 读取器返回 `meta.error`,宿主把它暴露出来,卡片上会给这个插件打上**问题标签**。
278
+ > 所以文件要留在包内(`files` 里已包含 `icons/`),并且必须是独立可用的 SVG——
279
+ > 直接用 `icons/base.svg` 是**无效**的,因为它里面的 `#p { fill: __COLOR__ }`
280
+ > 占位符并不是颜色。本仓库因此提供预填好的
281
+ > [`icons/plugin.svg`](./icons/plugin.svg)(即 `base.svg` 把 `__COLOR__` 换成
282
+ > `#1a1a1a`),并在 `test/verify.js` 里把整套约束都断言下来(F62–F65)。
283
+
284
+ 由于图标是从**已安装**的包读取的,本地改动只有在重新安装插件
285
+ (`dsh plugin --profile web add <path>`)或发布新版本之后才会生效。
286
+
239
287
  ## 实现原理
240
288
 
241
289
  - Host 插件 + 一个小型浏览器半区:在现有 `webServer` 上注册路由——状态 JSON 端点、静态 `/dsh-web-icon-indicator/base.svg`(鲸鱼模板),以及一个 `tapIndex` 向每个 `index.html` 注入小段浏览器脚本。整套配置就是插件导出的 `Config` schema,DSH settings 服务按 `dsh-web-icon-indicator` entry id 把它投射为实时配置页(见上)。
@@ -258,6 +306,7 @@ favicon 本质是一张图片,浏览器不会在标签页 UI 里运行 SVG 自
258
306
  已知限制(截至 Safari 26.3):
259
307
 
260
308
  - **favicon 有专属缓存。** Chrome 用 favicon 数据库、Firefox 用 `favicons.sqlite`、**Safari 用系统级图标缓存**——清普通缓存都清不掉,WebKit 甚至会把「无图标」这一状态也缓存起来。这就是改了图标后,已打开的标签页还可能显示旧图标的原因。本插件已通过「给 `base.svg` 与状态端点设置 `Cache-Control: no-store`、请求携带 freshness 参数(`?t=Date.now()`)、每次切换状态时重建 `<link rel=icon>` 节点」来缓解。
309
+ - **必须先折叠「按主题配对」的两个 favicon link(DSH ≥ 0.1.7)。** 宿主的 `index.html` 里有**两个** `rel=icon`——`favicon-dark.svg` 对应 `prefers-color-scheme: dark`、`favicon.svg` 对应浅色(0.1.6-alpha.2 及更早只有一个)。浏览器取的是**最后一个 media 命中的已连接 link**,所以只重绘第一个的话,无论会话状态怎么变,标签页始终显示宿主自己的图标。插件现在会在首次绘制前把这一组规范成**唯一一个不带 media 的 link**(保留你当前配色方案对应的那个)。如果你用的是 0.5.2 之前的版本、且标签页图标完全不变化,原因就在这里。
261
310
  - **Safari 渲染 SVG favicon,但忽略其内部 CSS**——不支持 `@media`、`prefers-color-scheme`、CSS 动画。所以所有上色必须烘焙进每一帧的标记(本插件正是这么做的),而不能依赖 CSS 变量。
262
311
  - **`data:` URI 的 SVG favicon 在 Safari 不可靠**(WebKit bug 236616,仍未关闭;Safari 17.6 复现)。本插件当前每帧都生成 `data:image/svg+xml` URI,因此在 Safari 上标签页图标可能完全不显示——这是最大的已知缺口。
263
312
  - **Safari 的动态 JS 更新为 hit-or-miss**,可能需要刷新一次;Safari 会「锁定」它首次看到的图标。目前没有保证可靠的、符合规范的手段能在 Safari 中实时更换 favicon。
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
2
+ <style>
3
+ #p { fill: #1a1a1a; }
4
+ </style>
5
+ <path id="p" fill="#1a1a1a" d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z"/>
6
+ </svg>
package/lib/index.js CHANGED
@@ -237,6 +237,36 @@ const INJECTED_SCRIPT = `
237
237
  );
238
238
  }
239
239
 
240
+ // Every DSH shell up to 0.1.6-alpha.2 carried ONE <link rel=icon>. From
241
+ // 0.1.7-alpha.1 the shell ships a THEME-SCOPED PAIR instead:
242
+ // <link rel=icon href=favicon-dark.svg media="(prefers-color-scheme: dark)">
243
+ // <link rel=icon href=favicon.svg media="(prefers-color-scheme: light)">
244
+ // Both links carry rel=icon, so querying just the first one and repainting
245
+ // it was silently useless: the browser resolves the favicon to the LAST
246
+ // connected link whose media condition matches, i.e. the second one — the
247
+ // one this script never touched. The tab then kept the shell's icon forever
248
+ // while the status endpoint changed underneath (the "icon never changes"
249
+ // bug). Normalize to ONE always-matching link first, then keep painting it.
250
+ function normalizeIconLinks() {
251
+ try {
252
+ var all = document.querySelectorAll("[rel~='icon']");
253
+ if (!all || !all.length) return;
254
+ var pick = all[0];
255
+ for (var i = 1; i < all.length; i++) {
256
+ // Prefer the variant the current scheme actually selects, so a
257
+ // dark-mode tab keeps the shell's dark artwork as its resting state.
258
+ var m = all[i].getAttribute("media");
259
+ if (m && window.matchMedia && window.matchMedia(m).matches) pick = all[i];
260
+ }
261
+ for (var j = 0; j < all.length; j++) {
262
+ var node = all[j];
263
+ if (node !== pick && typeof node.remove === "function") { try { node.remove(); } catch (e) {} }
264
+ }
265
+ // Drop the media condition: a link that no longer matches the scheme
266
+ // would be dead weight, and the frame we paint is scheme-agnostic.
267
+ try { pick.removeAttribute("media"); } catch (e) {}
268
+ } catch (e) {}
269
+ }
240
270
  // Capture the shell's own favicon ONCE, together with an offline-safe
241
271
  // data-URI copy of it. The copy matters: while the DSH host is stopped
242
272
  // the original href (a URL served by that same host) is unreachable, so
@@ -552,6 +582,7 @@ const INJECTED_SCRIPT = `
552
582
  });
553
583
  }
554
584
  captureOriginal();
585
+ normalizeIconLinks();
555
586
  TIMER = setInterval(poll, 1000);
556
587
  poll();
557
588
  // A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-web-icon-indicator",
3
3
  "description": "Browser tab favicon reflects the DSH session state: idle / running / asking / done, recolored and animated from one base SVG in the browser (fully configurable colors + effects). · 浏览器标签页 favicon 实时反映 DSH 会话状态:待机 / 运行中 / 提问 / 完成,由单个 base.svg 在浏览器中动态上色与动画(颜色与特效完全可配置)。",
4
- "version": "0.5.1",
4
+ "icon": "icons/plugin.svg",
5
+ "version": "0.5.2",
5
6
  "type": "module",
6
7
  "engines": {
7
8
  "dsh": ">=0.1.2-rc.1"
package/test/verify.js CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { register } from "node:module";
23
23
  import { spawnSync } from "node:child_process";
24
- import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
24
+ import { readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
25
25
  import vm from "node:vm";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
@@ -416,6 +416,15 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
416
416
  emit(h.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
417
417
  const askTimer = h.timers[h.timers.length - 1];
418
418
  ok("H10b ask hold timer scheduled", !!askTimer);
419
+ // The hold's whole point: a user who answers INSTANTLY must still see the
420
+ // asking icon. So the timer has to be armed for the resolved askingHoldMs and
421
+ // the pin has to survive every aggregate read until that timer fires — a live
422
+ // run cannot prove this (the answer's real latency dwarfs the hold).
423
+ eq("H10b2 hold waits the resolved askingHoldMs", askTimer.ms, 3500);
424
+ agg = aggregate();
425
+ eq("H10b3 still asking while the hold runs, though the answer already returned",
426
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
427
+ ok("H10b4 hold still pending (not fired, not disposed)", askTimer.fired === false);
419
428
  askTimer.cb(); // hold expired, answer already returned
420
429
  agg = aggregate();
421
430
  eq("H10c ask hold expired -> live running active 1",
@@ -879,29 +888,70 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
879
888
  // ============================================================================
880
889
  // PART 2 — browser injected script (real template, VM + DOM/fetch/rAF stubs)
881
890
  // ============================================================================
882
- function makeFakeLink(initialHref) {
883
- const attrs = { rel: "icon", type: "", href: initialHref || "" };
891
+ function makeFakeLink(initialHref, attrs) {
892
+ const a = { rel: "icon", type: "", href: initialHref || "", ...(attrs || {}) };
884
893
  return {
885
894
  isConnected: true,
886
- __attrs: attrs,
895
+ __attrs: a,
887
896
  setAttribute(k, v) {
888
- attrs[k] = String(v);
897
+ if (v === null || v === undefined) delete a[k];
898
+ else a[k] = String(v);
889
899
  },
890
900
  getAttribute(k) {
891
- return k in attrs ? attrs[k] : null;
901
+ return k in a ? a[k] : null;
902
+ },
903
+ // The real shell's favicon pair is scoped by a `media` condition, so the
904
+ // stub records its own media state and `remove()` detaches it — the two
905
+ // facts the fix's normalization has to act on.
906
+ hasMediaCondition(media) {
907
+ return a.media === media;
892
908
  },
909
+ remove() {
910
+ this.isConnected = false;
911
+ },
912
+ // A real replaceChild swaps NODES: the fresh link takes this one's place and
913
+ // does NOT inherit its attributes. Modelling that faithfully matters — the
914
+ // script relies on it to shed the shell's stale `media` condition, and the
915
+ // old "copy the fresh attrs onto the same object" shortcut hid exactly that.
893
916
  parentNode: {
917
+ __current: null,
894
918
  replaceChild(fresh) {
895
- // adopt the fresh node's attrs into this link
896
- for (const k of Object.keys(fresh.__attrs)) attrs[k] = fresh.__attrs[k];
919
+ fresh.__parent = this;
920
+ this.__current = fresh;
897
921
  },
898
922
  },
899
923
  };
900
924
  }
901
925
 
902
926
  class BrowserDriver {
903
- constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false } = {}) {
904
- this.link = makeFakeLink(initialHref);
927
+ constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false, links, matchMedia } = {}) {
928
+ this.links = links || [makeFakeLink(initialHref)];
929
+ this.matchMedia = matchMedia || (() => true); // a plain page: nothing is media-scoped
930
+ this.removed = []; // nodes detached by the script, in order
931
+ // Model a node swap faithfully (a real replaceChild does NOT copy the old
932
+ // node's attributes) and keep `links` in sync with the live DOM. The script
933
+ // replaces the <link> on every state change to defeat favicon caching, so
934
+ // this has to be installed on EVERY node — including the ones createElement
935
+ // makes later — or later swaps would silently leave `links` pointing at a
936
+ // detached node. (Getting this wrong hid the fix under test: the assertion
937
+ // kept reading the stale whale link.)
938
+ const patchSwap = (node) => {
939
+ node.parentNode.__current = node;
940
+ node.parentNode.replaceChild = (fresh) => {
941
+ const p = node.parentNode;
942
+ fresh.__parent = p;
943
+ p.__current = fresh;
944
+ const at = ctrl.links.indexOf(node);
945
+ if (at === -1) ctrl.links.push(fresh);
946
+ else ctrl.links[at] = fresh;
947
+ node.isConnected = false;
948
+ ctrl.removed.push(node);
949
+ patchSwap(fresh); // the replacement can be swapped again
950
+ };
951
+ return node;
952
+ };
953
+ for (const l of this.links) patchSwap(l);
954
+ const origCreate = (attrs) => patchSwap(makeFakeLink("", attrs));
905
955
  this.rafQueue = [];
906
956
  this.pollFn = null;
907
957
  this.queue = [];
@@ -917,10 +967,17 @@ class BrowserDriver {
917
967
  },
918
968
  document: {
919
969
  visibilityState: "visible",
920
- querySelector: (sel) => (sel.includes("icon") ? ctrl.link : null),
921
- createElement: () => makeFakeLink(""),
970
+ querySelector: (sel) => {
971
+ const all = ctrl.querySelectorAll(sel);
972
+ return all.length ? all[0] : null;
973
+ },
974
+ // [rel~='icon'] matches the real selector; 'icon' as a bare word keeps
975
+ // the historic `sel.includes("icon")` stub behavior for other callers.
976
+ querySelectorAll: (sel) => ctrl.querySelectorAll(sel),
977
+ createElement: () => origCreate(),
922
978
  head: { appendChild() {} },
923
979
  },
980
+ matchMedia: (q) => ({ matches: !!ctrl.matchMedia(q), media: q }),
924
981
  location: { origin: "http://localhost:3080" },
925
982
  setInterval: (fn) => {
926
983
  ctrl.pollFn = fn;
@@ -970,10 +1027,32 @@ class BrowserDriver {
970
1027
  this.context = context;
971
1028
  vm.runInContext(buildScript(states), context);
972
1029
  }
1030
+ // The `[rel~='icon']` selector the script uses, resolved against the stub
1031
+ // DOM: detached nodes are gone, so a normalized/removed link stops matching.
1032
+ querySelectorAll(sel) {
1033
+ if (!sel.includes("icon")) return [];
1034
+ return this.links.filter((l) => l.isConnected);
1035
+ }
1036
+ /** The link the browser would actually honour: the LAST connected one
1037
+ * (the last connected is what a browser resolves an unscoped rel=icon to). */
1038
+ get effectiveLink() {
1039
+ const connected = this.links.filter((l) => l.isConnected);
1040
+ // Honour `media` when the stub has one, so "run" and "assert" agree.
1041
+ const live = connected.filter((l) => l.__attrs.media === undefined || this.matchMedia(l.__attrs.media));
1042
+ const pick = live.length ? live : connected;
1043
+ return pick.length ? pick[pick.length - 1] : null;
1044
+ }
1045
+ /** The current favicon node. *Not* a stored property: the script replaces the
1046
+ * node (to defeat favicon caching), which would leave a stored reference
1047
+ * pointing at a detached element. */
1048
+ get link() {
1049
+ return this.effectiveLink;
1050
+ }
973
1051
  get href() {
974
1052
  // Favicon frames are encodeURIComponent'd data URIs; decode so assertions
975
1053
  // can match literal SVG substrings (rx="11", >3<, #E5484D, ...).
976
- return decodeURIComponent(this.link.__attrs.href || "");
1054
+ const l = this.effectiveLink || this.link;
1055
+ return decodeURIComponent(l.__attrs.href || "");
977
1056
  }
978
1057
  svgRes(text) {
979
1058
  return { ok: true, status: 200, text: async () => text, json: async () => ({}) };
@@ -1233,6 +1312,60 @@ console.log("\n=== Part 2: browser injected script ===");
1233
1312
  // poll a different state while visible and confirm the listener path stays safe.
1234
1313
  await v.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1235
1314
  ok("B18d subsequent polls unaffected after visible-return poll", v.href.includes("FACC15"));
1315
+
1316
+ // B19 the DSH >= 0.1.7 shell ships a THEME-SCOPED PAIR of favicon links:
1317
+ // <link rel=icon href=favicon-dark.svg media="(prefers-color-scheme: dark)">
1318
+ // <link rel=icon href=favicon.svg media="(prefers-color-scheme: light)">
1319
+ // The browser honours the LAST connected link whose media matches, so
1320
+ // repainting only the first one left the tab on the shell's icon forever
1321
+ // while the status endpoint changed underneath — the "icon never changes"
1322
+ // report. The script must collapse the pair onto one always-matching link.
1323
+ const shellPair = () => [
1324
+ makeFakeLink("http://localhost:3080/favicon-dark.svg", { media: "(prefers-color-scheme: dark)" }),
1325
+ makeFakeLink("http://localhost:3080/favicon.svg", { media: "(prefers-color-scheme: light)" }),
1326
+ ];
1327
+ const light = new BrowserDriver({
1328
+ links: shellPair(),
1329
+ matchMedia: (q) => q === "(prefers-color-scheme: light)",
1330
+ });
1331
+ await light.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1332
+ await light.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1333
+ ok("B19a a media-scoped pair collapses to ONE icon link at startup",
1334
+ light.links.filter((l) => l.isConnected).length === 1,
1335
+ "connected=" + light.links.filter((l) => l.isConnected).length);
1336
+ ok("B19b that link carries no media condition left to mis-match",
1337
+ light.effectiveLink.getAttribute("media") === null,
1338
+ JSON.stringify(light.effectiveLink.getAttribute("media")));
1339
+ ok("B19c the browser-effective href is the painted frame, not the shell icon",
1340
+ light.href.indexOf("data:image/svg+xml") === 0 && light.href.includes("FACC15"),
1341
+ light.href.slice(0, 90));
1342
+ // Dark scheme: the shell's dark variant is the one kept as the resting href.
1343
+ const dark = new BrowserDriver({
1344
+ links: shellPair(),
1345
+ matchMedia: (q) => q === "(prefers-color-scheme: dark)",
1346
+ });
1347
+ await dark.ready({ state: "idle", active: 0, states: DEFAULT_STATES });
1348
+ ok("B19d dark scheme keeps the dark variant (its own scheme wins)",
1349
+ dark.effectiveLink.__attrs.href.indexOf("favicon-dark.svg") !== -1 ||
1350
+ dark.effectiveLink.__attrs.href.indexOf("data:image/svg+xml") === 0,
1351
+ dark.effectiveLink.__attrs.href.slice(0, 90));
1352
+ const beforeRepaint = dark.links.length;
1353
+ await dark.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1354
+ ok("B19e the count-block/whale frame repaints the single kept link",
1355
+ dark.href.includes("FACC15"), dark.href.slice(0, 90));
1356
+ // A same-key re-poll mutates the live link in place and a key change swaps the
1357
+ // node (how the plugin defeats favicon caching). Either way the invariants
1358
+ // are: exactly one link stays connected, its href is a painted data: URI, and
1359
+ // the retired shell link never comes back.
1360
+ await dark.poll({ state: "done", active: 1, states: DEFAULT_STATES });
1361
+ ok("B19f repainting keeps exactly one connected link, still the painted one",
1362
+ dark.links.filter((l) => l.isConnected).length === 1 &&
1363
+ dark.links.length === 2 && // the retired shell link never returns
1364
+ dark.links.filter((l) => !l.isConnected).length === 1 &&
1365
+ dark.href.indexOf("data:image/svg+xml") === 0,
1366
+ "before=" + beforeRepaint + " now=" + dark.links.length + " connected=" +
1367
+ dark.links.map((l) => (l.isConnected ? "+" : "-")).join("") +
1368
+ " href=" + dark.href.slice(0, 60));
1236
1369
  }
1237
1370
 
1238
1371
  // ============================================================================
@@ -2260,6 +2393,59 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
2260
2393
  CLIENT_SRC.indexOf("configForms") !== -1 &&
2261
2394
  CLIENT_SRC.indexOf("plugins.row.config") !== -1 &&
2262
2395
  CLIENT_SRC.indexOf("whileServed") !== -1);
2396
+
2397
+ // ---- official Plugins page icon (host manifest contract) ---------------
2398
+ // The 0.1.7 page reads `package.json.icon` through <pkg>/package.json and
2399
+ // base64s the file (readPluginMeta -> iconOf in @deepseek-ai/dsh-app-boot):
2400
+ // a manifest-relative SVG/PNG/JPEG/WebP at most 256 KiB, contained in the
2401
+ // manifest directory after realpath. Any violation does not blank the card
2402
+ // — it turns the plugin's `meta.error` on and the card shows a problem tag,
2403
+ // so every rule below is load-bearing.
2404
+ const MANIFEST = JSON.parse(readFileSync(new URL("package.json", REPO), "utf8"));
2405
+ const ICON_REL = MANIFEST.icon;
2406
+ const ICON_EXT = typeof ICON_REL === "string" ? ICON_REL.slice(ICON_REL.lastIndexOf(".")).toLowerCase() : "";
2407
+ ok("F62 the manifest declares a relative image icon",
2408
+ typeof ICON_REL === "string" && ICON_REL.trim() !== "" &&
2409
+ !/^[A-Za-z][A-Za-z\d+.-]*:/u.test(ICON_REL) && // rejects URLs (and Windows drive letters)
2410
+ !ICON_REL.startsWith("/") && !ICON_REL.startsWith("\\") &&
2411
+ !/^[A-Za-z]:/u.test(ICON_REL) && // rejects C:\... absolute paths
2412
+ !ICON_REL.split(/[\\/]/u).includes("..") && // must stay inside the manifest directory
2413
+ [".svg", ".png", ".jpg", ".jpeg", ".webp"].includes(ICON_EXT),
2414
+ JSON.stringify(ICON_REL));
2415
+
2416
+ let ICON_SVG = "";
2417
+ let ICON_BYTES = 0;
2418
+ try {
2419
+ ICON_SVG = readFileSync(new URL(ICON_REL, REPO), "utf8");
2420
+ ICON_BYTES = statSync(new URL(ICON_REL, REPO)).size;
2421
+ } catch {
2422
+ /* the F63 check below reports the missing file */
2423
+ }
2424
+ ok("F63 the declared icon ships and stays under the 256 KiB manifest bound",
2425
+ ICON_BYTES > 0 && ICON_BYTES <= 256 * 1024,
2426
+ ICON_BYTES + " bytes");
2427
+
2428
+ // A leftover __COLOR__ placeholder is not valid SVG: the page would render
2429
+ // an unpainted whale (or nothing) where the artwork goes.
2430
+ ok("F64 the icon is standalone SVG with no placeholder left in it",
2431
+ ICON_SVG !== "" &&
2432
+ ICON_SVG.startsWith("<svg ") && ICON_SVG.trimEnd().endsWith("</svg>") &&
2433
+ !ICON_SVG.includes("__COLOR__"),
2434
+ ICON_SVG.slice(0, 40));
2435
+
2436
+ // icons/plugin.svg and docs/assets/favicon.svg are the same static whale;
2437
+ // keep them byte-identical so regenerating one from icons/base.svg cannot
2438
+ // silently leave the other stale. Repo-only (absent from the npm tarball).
2439
+ let DOCS_FAVICON = null;
2440
+ try {
2441
+ DOCS_FAVICON = readFileSync(new URL("docs/assets/favicon.svg", REPO), "utf8");
2442
+ } catch {
2443
+ /* published tarball: docs/ is repo-only */
2444
+ }
2445
+ if (DOCS_FAVICON !== null) {
2446
+ ok("F65 icons/plugin.svg stays identical to docs/assets/favicon.svg",
2447
+ DOCS_FAVICON === ICON_SVG);
2448
+ }
2263
2449
  }
2264
2450
  }
2265
2451