dsh-web-icon-indicator 0.5.1 → 0.6.0
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 +16 -0
- package/README.md +65 -6
- package/README.zh.md +56 -5
- package/icons/plugin.svg +6 -0
- package/lib/client.js +68 -13
- package/lib/index.js +31 -0
- package/lib/types/client/index.d.ts +10 -4
- package/lib/types/index.d.ts +2 -1
- package/package.json +2 -1
- package/test/verify.js +312 -18
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.2...v0.6.0) (2026-09-24)
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
* show the settings form on the plugin's own page ([a85a8b0](https://github.com/waknow/dsh-web-icon-indicator/commit/a85a8b0ce3821743c45bd8cca6ea0d969e8a419f))
|
|
15
|
+
|
|
16
|
+
## [0.5.2](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.1...v0.5.2) (2026-09-24)
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* give the official Plugins page its own artwork ([332896b](https://github.com/waknow/dsh-web-icon-indicator/commit/332896b4b05af94877e63a33ad9c4895b94ad7dd))
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
* repaint the favicon on DSH 0.1.7's theme-scoped link pair ([a57f78f](https://github.com/waknow/dsh-web-icon-indicator/commit/a57f78ff98acd4d9ae41c03c3b0519113e357bb2))
|
|
25
|
+
|
|
10
26
|
## [0.5.1](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.0...v0.5.1) (2026-09-22)
|
|
11
27
|
|
|
12
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/dsh-web-icon-indicator)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
|
|
10
|
-
> **⚠️ DSH version support** — requires **DSH ≥ 0.1.2-rc.1**. One bundle serves both settings generations: **modern** (≥ 0.1.7-alpha.1: exported `Config` schema + `configForms` + `plugins.
|
|
10
|
+
> **⚠️ DSH version support** — requires **DSH ≥ 0.1.2-rc.1**. One bundle serves both settings generations: **modern** (≥ 0.1.7-alpha.1: exported `Config` schema + `configForms` + `plugins.bundle.config`) and **legacy** (≤ 0.1.6-alpha.1: `settings.installSection` + `settingsScope` + `settings.plugin.item`). Verified on **DSH 0.1.5-rc.3** and **DSH 0.1.7-alpha.1**; on any older host the favicon still works even if the settings page is not reachable.
|
|
11
11
|
|
|
12
12
|
Browser tab favicon reflects the current DSH session state — `idle` / `running` / `asking` / `done` — so you can see at a glance whether a session needs your attention, even when the tab is in the background.
|
|
13
13
|
|
|
@@ -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
|
|
@@ -216,9 +227,12 @@ patch declares); **≤ 0.1.6-alpha.1** uses the plugin-chosen
|
|
|
216
227
|
`web-icon-indicator`, the same string 0.5.x used, so an existing section keeps
|
|
217
228
|
resolving:
|
|
218
229
|
|
|
219
|
-
- **Web GUI:** open **设置 →
|
|
220
|
-
|
|
221
|
-
|
|
230
|
+
- **Web GUI:** open **设置 → 插件** and click the **dsh-web-icon-indicator**
|
|
231
|
+
card — the settings form opens directly on the plugin's page (the same place
|
|
232
|
+
the official plugins put theirs) — including on **0.1.6-alpha.2**, which still
|
|
233
|
+
binds through the legacy `settingsScope` but already renders the bundle slot.
|
|
234
|
+
The
|
|
235
|
+
*Favicon indicator* page edits the same keys: asking/done hold, the **default icon color** (with a
|
|
222
236
|
live palette preview and the similarity warning), and the per-state effect /
|
|
223
237
|
colors / cycle for `running` / `asking` / `done`. Each of those states is a
|
|
224
238
|
collapsible row whose header shows one **color chip** per state — split in two
|
|
@@ -263,14 +277,58 @@ resolving:
|
|
|
263
277
|
omitted from the resolved form until a user sets it.
|
|
264
278
|
- The browser half is a hand-written `lib/client.js` (ModuleLoader factory
|
|
265
279
|
format — no build step, no runtime deps beyond the shell's `react`). It
|
|
266
|
-
registers on
|
|
267
|
-
|
|
280
|
+
registers on the slot the running host declares — `plugins.bundle.config`
|
|
281
|
+
on ≥ 0.1.7 (so the Plugins list's card opens straight onto the form), else
|
|
282
|
+
`plugins.row.config`; both gated on the served namespace — plus the legacy
|
|
283
|
+
`settings.plugin.item` — and resolves whichever
|
|
268
284
|
settings provider the host exposes (`configForms` or `settingsScope`) at
|
|
269
285
|
render time, so neither is a hard dependency of the module. The DSH client
|
|
270
286
|
scanner picks a new `dsh.client` declaration up on the next profile start.
|
|
271
287
|
- Deployments without a settings service are unaffected: the plugin keeps
|
|
272
288
|
running on the composition entry + schema defaults it was mounted with.
|
|
273
289
|
|
|
290
|
+
## Plugin artwork on the Plugins page
|
|
291
|
+
|
|
292
|
+
DSH ≥ 0.1.7 renders a **per-plugin icon on the official Plugins page**: 48 px on
|
|
293
|
+
each bundle card, 40 px on each row (`PackageArtwork` in
|
|
294
|
+
`@deepseek-ai/dsh-client-ui-plugin-manager`, which falls back to a generic
|
|
295
|
+
placeholder when a package declares none). The artwork comes straight from the
|
|
296
|
+
package manifest, so shipping one is pure metadata — **no code, no settings, no
|
|
297
|
+
host restart**:
|
|
298
|
+
|
|
299
|
+
```jsonc
|
|
300
|
+
// package.json
|
|
301
|
+
{
|
|
302
|
+
"name": "dsh-web-icon-indicator",
|
|
303
|
+
"icon": "icons/plugin.svg" // relative to package.json, and inside the package
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The host reads it before evaluating any plugin code
|
|
308
|
+
(`readPluginMeta` → `iconOf` in `@deepseek-ai/dsh-app-boot`):
|
|
309
|
+
|
|
310
|
+
| Rule | Value |
|
|
311
|
+
| --- | --- |
|
|
312
|
+
| Field | top-level `package.json.icon` (a sibling of `name`/`version` — **not** under `dsh`) |
|
|
313
|
+
| Path form | relative to the manifest's directory; absolute paths, Windows drive letters (`C:\…`) and URLs are rejected |
|
|
314
|
+
| Containment | must stay inside the manifest directory after `realpath` resolution (`..` escapes are rejected) |
|
|
315
|
+
| Formats | `.svg` → `image/svg+xml`, `.png` → `image/png`, `.jpg` / `.jpeg` → `image/jpeg`, `.webp` → `image/webp` |
|
|
316
|
+
| Size | ≤ 256 KiB (checked both on `stat` and on the bytes actually read) |
|
|
317
|
+
| Delivery | read as bytes and shipped to the browser as a base64 `data:` URL on the wire (`PluginLocalizedMeta.icon`) |
|
|
318
|
+
|
|
319
|
+
> ⚠️ **A bad icon is worse than no icon.** Any violation is a *metadata error*,
|
|
320
|
+
> not a silent fallback: the reader returns `meta.error`, the host surfaces it,
|
|
321
|
+
> and the card shows a **problem tag** for the plugin. Keep the file in the
|
|
322
|
+
> package (`files` ships `icons/`) and make sure it is standalone — a raw
|
|
323
|
+
> `icons/base.svg` would be **invalid**, because its `#p { fill: __COLOR__ }`
|
|
324
|
+
> placeholder is not a colour. This repo therefore ships the pre-filled
|
|
325
|
+
> [`icons/plugin.svg`](./icons/plugin.svg) (`base.svg` with `__COLOR__` →
|
|
326
|
+
> `#1a1a1a`) and asserts the whole contract in `test/verify.js` (F62–F65).
|
|
327
|
+
|
|
328
|
+
Because the icon is read from the **installed** package, a local edit takes
|
|
329
|
+
effect only after reinstalling the plugin (`dsh plugin --profile web add
|
|
330
|
+
<path>`) or publishing a new version.
|
|
331
|
+
|
|
274
332
|
## How it works
|
|
275
333
|
|
|
276
334
|
- 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 +351,7 @@ The favicon is a plain image, so browsers never run the SVG's own CSS/JS animati
|
|
|
293
351
|
Known limitations (current as of Safari 26.3):
|
|
294
352
|
|
|
295
353
|
- **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.
|
|
354
|
+
- **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
355
|
- **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
356
|
- **`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
357
|
- **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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/dsh-web-icon-indicator)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
|
|
10
|
-
> **⚠️ DSH 版本支持** —— 需要 **DSH ≥ 0.1.2-rc.1**。同一份插件同时兼容两代 settings 契约:**新一代**(≥ 0.1.7-alpha.1:导出 `Config` schema + `configForms` + `plugins.
|
|
10
|
+
> **⚠️ DSH 版本支持** —— 需要 **DSH ≥ 0.1.2-rc.1**。同一份插件同时兼容两代 settings 契约:**新一代**(≥ 0.1.7-alpha.1:导出 `Config` schema + `configForms` + `plugins.bundle.config`)与**旧一代**(≤ 0.1.6-alpha.1:`settings.installSection` + `settingsScope` + `settings.plugin.item`)。已在 **DSH 0.1.5-rc.3** 与 **DSH 0.1.7-alpha.1** 上验证;更早的宿主上 favicon 仍可用,只是配置页可能不可达。
|
|
11
11
|
|
|
12
12
|
浏览器标签页 favicon 实时反映 DSH 会话状态——`待机` / `运行中` / `提问` / `完成`——让你在标签页置于后台时也能一眼看出是否有会话需要处理。
|
|
13
13
|
|
|
@@ -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
|
|
@@ -195,8 +205,9 @@ schemastery schema),**并在宿主仍提供旧版服务时**用同一份 sch
|
|
|
195
205
|
表单,即 `dsh-web-icon-indicator`(bundle patch 声明的行 id);**≤ 0.1.6-alpha.1**
|
|
196
206
|
使用插件自定的 `web-icon-indicator`,与 0.5.x 保持一致,老用户的 section 继续生效:
|
|
197
207
|
|
|
198
|
-
- **Web GUI:** 打开 **设置 →
|
|
199
|
-
|
|
208
|
+
- **Web GUI:** 打开 **设置 → 插件**,点开 **dsh-web-icon-indicator** 卡片,
|
|
209
|
+
设置表单就直接出现在插件页上(与官方插件的位置一致);**0.1.6-alpha.2** 虽然还在用
|
|
210
|
+
旧的 `settingsScope`,但它同样渲染 bundle 插槽,因此表单位置一致。可编辑:提问/完成驻留、
|
|
200
211
|
**默认图标颜色**(带配色一览与相似度告警),以及
|
|
201
212
|
`running` / `asking` / `done` 三个状态各自的特效 / 颜色 / 周期。这三个状态
|
|
202
213
|
各占一行可折叠条目,行首是一个**色块**——多色状态(asking)会左右分格同时显示
|
|
@@ -229,13 +240,52 @@ schemastery schema),**并在宿主仍提供旧版服务时**用同一份 sch
|
|
|
229
240
|
- 因此实时设置面覆盖 `askingHoldMs`、`doneHoldMs`、`iconsDir`、`defaultColor`
|
|
230
241
|
与 `states`。`iconsDir` 没有 schema 默认值,用户未设置时不会出现在解析后的表单中。
|
|
231
242
|
- 浏览器半区是手写的 `lib/client.js`(ModuleLoader factory 格式——无构建步骤、
|
|
232
|
-
无额外运行期依赖,仅用 shell 自带的 `react
|
|
233
|
-
|
|
243
|
+
无额外运行期依赖,仅用 shell 自带的 `react`)。它按宿主实际声明的插槽注册
|
|
244
|
+
(≥ 0.1.7 用 `plugins.bundle.config`,这样插件列表的卡片点开即见表单;否则退回
|
|
245
|
+
`plugins.row.config`——两者都按「Host 是否提供该命名空间」门控;另有旧的
|
|
234
246
|
`settings.plugin.item`),并在渲染时按宿主实际提供的服务解析表单
|
|
235
247
|
(`configForms` 或 `settingsScope`),因此两者都不是模块的硬依赖。DSH 客户端
|
|
236
248
|
扫描器会在下次启动 profile 时识别新的 `dsh.client` 声明。
|
|
237
249
|
- 未组合 settings 服务的部署不受影响:插件继续使用挂载时的合成条目 + schema 默认值。
|
|
238
250
|
|
|
251
|
+
## 插件页图标
|
|
252
|
+
|
|
253
|
+
DSH ≥ 0.1.7 会在官方**插件页**为每个插件渲染图标:bundle 卡片 48px、每一行
|
|
254
|
+
40px(`@deepseek-ai/dsh-client-ui-plugin-manager` 的 `PackageArtwork`,未声明时
|
|
255
|
+
回退到通用占位图)。图标直接来自包清单,所以给出它纯粹是元数据——**不需要改代码、
|
|
256
|
+
不需要配置、不需要重启宿主**:
|
|
257
|
+
|
|
258
|
+
```jsonc
|
|
259
|
+
// package.json
|
|
260
|
+
{
|
|
261
|
+
"name": "dsh-web-icon-indicator",
|
|
262
|
+
"icon": "icons/plugin.svg" // 相对 package.json,且必须留在包内
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
宿主在**不执行任何插件代码**的前提下读取它(`@deepseek-ai/dsh-app-boot` 的
|
|
267
|
+
`readPluginMeta` → `iconOf`):
|
|
268
|
+
|
|
269
|
+
| 规则 | 取值 |
|
|
270
|
+
| --- | --- |
|
|
271
|
+
| 字段 | 顶层 `package.json.icon`(与 `name`/`version` 同级,**不是**放在 `dsh` 下) |
|
|
272
|
+
| 路径形式 | 相对清单所在目录;绝对路径、Windows 盘符(`C:\…`)与 URL 都会被拒绝 |
|
|
273
|
+
| 包含关系 | 经 `realpath` 解析后必须仍在清单目录内(`..` 越界会被拒绝) |
|
|
274
|
+
| 格式 | `.svg` → `image/svg+xml`,`.png` → `image/png`,`.jpg` / `.jpeg` → `image/jpeg`,`.webp` → `image/webp` |
|
|
275
|
+
| 大小 | ≤ 256 KiB(`stat` 与实际读到的字节数各校验一次) |
|
|
276
|
+
| 传输 | 读成字节后以 base64 `data:` URL 随元数据下发(`PluginLocalizedMeta.icon`) |
|
|
277
|
+
|
|
278
|
+
> ⚠️ **坏的图标比没有图标更糟。** 任何不合规都不是静默回退,而是**元数据错误**:
|
|
279
|
+
> 读取器返回 `meta.error`,宿主把它暴露出来,卡片上会给这个插件打上**问题标签**。
|
|
280
|
+
> 所以文件要留在包内(`files` 里已包含 `icons/`),并且必须是独立可用的 SVG——
|
|
281
|
+
> 直接用 `icons/base.svg` 是**无效**的,因为它里面的 `#p { fill: __COLOR__ }`
|
|
282
|
+
> 占位符并不是颜色。本仓库因此提供预填好的
|
|
283
|
+
> [`icons/plugin.svg`](./icons/plugin.svg)(即 `base.svg` 把 `__COLOR__` 换成
|
|
284
|
+
> `#1a1a1a`),并在 `test/verify.js` 里把整套约束都断言下来(F62–F65)。
|
|
285
|
+
|
|
286
|
+
由于图标是从**已安装**的包读取的,本地改动只有在重新安装插件
|
|
287
|
+
(`dsh plugin --profile web add <path>`)或发布新版本之后才会生效。
|
|
288
|
+
|
|
239
289
|
## 实现原理
|
|
240
290
|
|
|
241
291
|
- Host 插件 + 一个小型浏览器半区:在现有 `webServer` 上注册路由——状态 JSON 端点、静态 `/dsh-web-icon-indicator/base.svg`(鲸鱼模板),以及一个 `tapIndex` 向每个 `index.html` 注入小段浏览器脚本。整套配置就是插件导出的 `Config` schema,DSH settings 服务按 `dsh-web-icon-indicator` entry id 把它投射为实时配置页(见上)。
|
|
@@ -258,6 +308,7 @@ favicon 本质是一张图片,浏览器不会在标签页 UI 里运行 SVG 自
|
|
|
258
308
|
已知限制(截至 Safari 26.3):
|
|
259
309
|
|
|
260
310
|
- **favicon 有专属缓存。** Chrome 用 favicon 数据库、Firefox 用 `favicons.sqlite`、**Safari 用系统级图标缓存**——清普通缓存都清不掉,WebKit 甚至会把「无图标」这一状态也缓存起来。这就是改了图标后,已打开的标签页还可能显示旧图标的原因。本插件已通过「给 `base.svg` 与状态端点设置 `Cache-Control: no-store`、请求携带 freshness 参数(`?t=Date.now()`)、每次切换状态时重建 `<link rel=icon>` 节点」来缓解。
|
|
311
|
+
- **必须先折叠「按主题配对」的两个 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
312
|
- **Safari 渲染 SVG favicon,但忽略其内部 CSS**——不支持 `@media`、`prefers-color-scheme`、CSS 动画。所以所有上色必须烘焙进每一帧的标记(本插件正是这么做的),而不能依赖 CSS 变量。
|
|
262
313
|
- **`data:` URI 的 SVG favicon 在 Safari 不可靠**(WebKit bug 236616,仍未关闭;Safari 17.6 复现)。本插件当前每帧都生成 `data:image/svg+xml` URI,因此在 Safari 上标签页图标可能完全不显示——这是最大的已知缺口。
|
|
263
314
|
- **Safari 的动态 JS 更新为 hit-or-miss**,可能需要刷新一次;Safari 会「锁定」它首次看到的图标。目前没有保证可靠的、符合规范的手段能在 Safari 中实时更换 favicon。
|
package/icons/plugin.svg
ADDED
|
@@ -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/client.js
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* Registers one configuration page and serves BOTH host generations from one
|
|
6
6
|
* bundle:
|
|
7
7
|
*
|
|
8
|
-
* - DSH ≥ 0.1.7 (modern): page
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - DSH ≥ 0.1.7 (modern): the official Plugins page, bound to the
|
|
9
|
+
* profile-entry settings namespace through `configForms`
|
|
10
|
+
* (`ctx.configForms.get(entryId)`). The form registers into
|
|
11
|
+
* `plugins.bundle.config` keyed by the bundle's package name, which the
|
|
12
|
+
* bundle's page renders under its description — the page a card in the
|
|
13
|
+
* Plugins list opens, so the settings are one click from that list (the
|
|
14
|
+
* shape the official plugins use). A host that declares only the row slot —
|
|
15
|
+
* no released host does (see AGENTS.md) — gets the row's configure control
|
|
16
|
+
* instead; the two are mutually exclusive, never both.
|
|
11
17
|
* - DSH ≤ 0.1.6-alpha.1 (legacy): page slot `settings.plugin.item` keyed by the
|
|
12
18
|
* legacy `web-icon-indicator` namespace, bound through `settingsScope`.
|
|
13
19
|
*
|
|
@@ -57,6 +63,14 @@ window.__ModuleLoader__.load({
|
|
|
57
63
|
* settings tab dispatches in `settings.plugin.item`.
|
|
58
64
|
*/
|
|
59
65
|
var LEGACY_NS = "web-icon-indicator";
|
|
66
|
+
/**
|
|
67
|
+
* `plugins.bundle.config` key: the bundle's own package name. The bundle
|
|
68
|
+
* page renders this entry between its description and its rows, and that
|
|
69
|
+
* page is exactly what a card in the Plugins list opens — so this is the
|
|
70
|
+
* slot that keeps the settings one click from the list. Must match the
|
|
71
|
+
* installed package name in `package.json`.
|
|
72
|
+
*/
|
|
73
|
+
var BUNDLE_KEY = "dsh-web-icon-indicator";
|
|
60
74
|
/** `plugins.row.config` key: `<bundle package name>#<row id as the patch declares it>`. */
|
|
61
75
|
var ROW_KEY = "dsh-web-icon-indicator#dsh-web-icon-indicator";
|
|
62
76
|
var LOCALE = "dsh-web-icon-indicator";
|
|
@@ -1355,26 +1369,67 @@ window.__ModuleLoader__.load({
|
|
|
1355
1369
|
// card follows whichever provider is mounted and renders nothing while
|
|
1356
1370
|
// neither is.
|
|
1357
1371
|
var pageFace = function () { return { scope: resolveScope(ctx), t: t }; };
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
// - `
|
|
1372
|
+
// One settings card, three page slots across the host generations.
|
|
1373
|
+
// `ctx.slots.inject(key, …)` is a no-op for a slot this host never
|
|
1374
|
+
// declares, so `start` runs exactly the branches the running host
|
|
1375
|
+
// understands. A DSH ≥ 0.1.7 host declares BOTH modern slots, so those
|
|
1376
|
+
// two branches are mutually exclusive:
|
|
1377
|
+
// - `plugins.bundle.config` (≥ 0.1.7): keyed by the bundle's own package
|
|
1378
|
+
// name. The bundle page renders it under the description, and that
|
|
1379
|
+
// page is exactly what a card in the Plugins list opens — so the
|
|
1380
|
+
// settings sit one click from the list. This is the shape the
|
|
1381
|
+
// official plugins use (the official voice-input bundle registers
|
|
1382
|
+
// this slot).
|
|
1383
|
+
// - `plugins.row.config` (≥ 0.1.6-alpha.2): keyed `<package>#<row id>`,
|
|
1384
|
+
// it only adds the configure control on the bundle page's row, which
|
|
1385
|
+
// buries the form one level deeper (bundle page -> row -> configure).
|
|
1386
|
+
// Registered only where the bundle slot is absent — a guard no
|
|
1387
|
+
// released host reaches, since every host with a Plugins page
|
|
1388
|
+
// declares the bundle slot too (see AGENTS.md).
|
|
1389
|
+
// - `settings.plugin.item` (≤ 0.1.6-alpha.1): keyed by the legacy
|
|
1364
1390
|
// settings namespace, dispatched by the settings tab per served
|
|
1365
1391
|
// namespace.
|
|
1366
1392
|
var start = function () {
|
|
1367
|
-
var
|
|
1393
|
+
var rowInject = null;
|
|
1394
|
+
var bundleLive = false;
|
|
1395
|
+
var dropRow = function () {
|
|
1396
|
+
var cancelRow = rowInject;
|
|
1397
|
+
rowInject = null;
|
|
1398
|
+
if (cancelRow !== null) cancelRow();
|
|
1399
|
+
};
|
|
1400
|
+
var disposeBundle = ctx.slots.inject("plugins.bundle.config", function () {
|
|
1401
|
+
bundleLive = true;
|
|
1402
|
+
// The bundle slot owns the card wherever it exists: cancel the row
|
|
1403
|
+
// wait, or dispose a row registration the host declared first, so the
|
|
1404
|
+
// same form is never drawn twice on one page.
|
|
1405
|
+
dropRow();
|
|
1368
1406
|
return ctx.slots.register(
|
|
1369
1407
|
{
|
|
1370
|
-
name: "plugins.
|
|
1371
|
-
key:
|
|
1408
|
+
name: "plugins.bundle.config",
|
|
1409
|
+
key: BUNDLE_KEY,
|
|
1372
1410
|
locale: LOCALE,
|
|
1373
1411
|
inject: pageFace,
|
|
1374
1412
|
},
|
|
1375
1413
|
IconConfigCard
|
|
1376
1414
|
);
|
|
1377
1415
|
});
|
|
1416
|
+
if (!bundleLive) {
|
|
1417
|
+
rowInject = ctx.slots.inject("plugins.row.config", function () {
|
|
1418
|
+
// The bundle slot may still be declared after this one (both slots
|
|
1419
|
+
// arrive with the Plugins page's own registration): stay out of its
|
|
1420
|
+
// way rather than registering a second copy of the same card.
|
|
1421
|
+
if (bundleLive) return function () {};
|
|
1422
|
+
return ctx.slots.register(
|
|
1423
|
+
{
|
|
1424
|
+
name: "plugins.row.config",
|
|
1425
|
+
key: ROW_KEY,
|
|
1426
|
+
locale: LOCALE,
|
|
1427
|
+
inject: pageFace,
|
|
1428
|
+
},
|
|
1429
|
+
IconConfigCard
|
|
1430
|
+
);
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1378
1433
|
var disposeItem = ctx.slots.inject("settings.plugin.item", function () {
|
|
1379
1434
|
return ctx.slots.register(
|
|
1380
1435
|
{
|
|
@@ -1386,7 +1441,7 @@ window.__ModuleLoader__.load({
|
|
|
1386
1441
|
IconConfigCard
|
|
1387
1442
|
);
|
|
1388
1443
|
});
|
|
1389
|
-
return function () {
|
|
1444
|
+
return function () { disposeBundle(); dropRow(); disposeItem(); };
|
|
1390
1445
|
};
|
|
1391
1446
|
// On the modern line, gate registration on the served namespace so an
|
|
1392
1447
|
// uncomposed plugin shows no page; `whileServed` owns the returned
|
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
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* Browser half of dsh-web-icon-indicator (dsh.client bundle).
|
|
3
3
|
*
|
|
4
4
|
* Registers one configuration page and serves both host generations from one
|
|
5
|
-
* bundle: `plugins.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* bundle. Modern (DSH ≥ 0.1.7): `plugins.bundle.config` keyed by the bundle
|
|
6
|
+
* package name `dsh-web-icon-indicator`, so the bundle page a Plugins-list card
|
|
7
|
+
* opens renders the form directly; a host that declares only
|
|
8
|
+
* `plugins.row.config` (no released host does) gets that slot instead, keyed
|
|
9
|
+
* `dsh-web-icon-indicator#dsh-web-icon-indicator` — never both. Either binds
|
|
10
|
+
* through `configForms` on ≥ 0.1.7 or `settingsScope` on 0.1.6-alpha.2 (which
|
|
11
|
+
* already renders the bundle slot). Legacy (DSH ≤ 0.1.6-alpha.1):
|
|
12
|
+
* `settings.plugin.item`, keyed by the legacy `web-icon-indicator` namespace,
|
|
13
|
+
* bound through `settingsScope`.
|
|
14
|
+
*
|
|
9
15
|
* Neither settings provider appears in `inject`: both are read weakly at render
|
|
10
16
|
* time, so the fiber activates on either line.
|
|
11
17
|
*/
|
package/lib/types/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* The same surface is registered with the DSH settings service under the
|
|
22
22
|
* `web-icon-indicator` namespace (schemastery schema in `lib/index.js`): it is
|
|
23
23
|
* validated, persisted into the profile's `settings.yaml`, and editable from
|
|
24
|
-
* 设置 → 插件
|
|
24
|
+
* the Plugins page (设置 → 插件) through the browser half (`./client`) — the
|
|
25
|
+
* settings form is registered on the plugin's own page. While no
|
|
25
26
|
* settings service is composed, the plugin reads the composition entry only.
|
|
26
27
|
*/
|
|
27
28
|
|
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
|
-
"
|
|
4
|
+
"icon": "icons/plugin.svg",
|
|
5
|
+
"version": "0.6.0",
|
|
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
|
|
891
|
+
function makeFakeLink(initialHref, attrs) {
|
|
892
|
+
const a = { rel: "icon", type: "", href: initialHref || "", ...(attrs || {}) };
|
|
884
893
|
return {
|
|
885
894
|
isConnected: true,
|
|
886
|
-
__attrs:
|
|
895
|
+
__attrs: a,
|
|
887
896
|
setAttribute(k, v) {
|
|
888
|
-
|
|
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
|
|
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
|
-
|
|
896
|
-
|
|
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.
|
|
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) =>
|
|
921
|
-
|
|
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
|
-
|
|
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
|
// ============================================================================
|
|
@@ -1442,6 +1575,7 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
|
1442
1575
|
const makeClientCtx = (opts) => {
|
|
1443
1576
|
const declared = new Set(opts.declared || []);
|
|
1444
1577
|
const services = opts.services || {};
|
|
1578
|
+
const sink = opts.sink || registered;
|
|
1445
1579
|
return {
|
|
1446
1580
|
get(name) { return services[name]; },
|
|
1447
1581
|
locale: {
|
|
@@ -1450,13 +1584,23 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
|
1450
1584
|
},
|
|
1451
1585
|
slots: {
|
|
1452
1586
|
inject(name, cb) { if (declared.has(name)) cb(); return () => {}; },
|
|
1453
|
-
register(slotSpec, component) {
|
|
1587
|
+
register(slotSpec, component) {
|
|
1588
|
+
const entry = { slotSpec, component, disposed: false };
|
|
1589
|
+
sink.push(entry);
|
|
1590
|
+
return () => { entry.disposed = true; };
|
|
1591
|
+
},
|
|
1454
1592
|
},
|
|
1455
1593
|
effect(fn) { return fn(); },
|
|
1456
1594
|
};
|
|
1457
1595
|
};
|
|
1596
|
+
// A DSH >= 0.1.7 host declares BOTH configuration slots when the Plugins
|
|
1597
|
+
// page's own registration commits, and the card must land in exactly one of
|
|
1598
|
+
// them: `plugins.bundle.config` renders on the bundle's page — the page a
|
|
1599
|
+
// Plugins-list card opens, so one click from the list — while
|
|
1600
|
+
// `plugins.row.config` only puts a configure control on that page's row,
|
|
1601
|
+
// one level deeper. Registering into both would draw the same form twice.
|
|
1458
1602
|
const clientCtx = makeClientCtx({
|
|
1459
|
-
declared: ["plugins.row.config"],
|
|
1603
|
+
declared: ["plugins.bundle.config", "plugins.row.config"],
|
|
1460
1604
|
services: {
|
|
1461
1605
|
configForms: {
|
|
1462
1606
|
get(entryId) { boundEntryId = entryId; return scope; },
|
|
@@ -1469,15 +1613,18 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
|
1469
1613
|
ok("F6 locale dictionaries registered (en + zh)",
|
|
1470
1614
|
dictionaries.length === 1 && !!dictionaries[0].dicts.en && !!dictionaries[0].dicts.zh,
|
|
1471
1615
|
"ns=" + (dictionaries[0] && dictionaries[0].ns));
|
|
1472
|
-
ok("F7 modern line: one card registered into plugins.
|
|
1473
|
-
registered.length === 1 && registered[0].slotSpec.name === "plugins.
|
|
1616
|
+
ok("F7 modern line: one card registered into plugins.bundle.config",
|
|
1617
|
+
registered.length === 1 && registered[0].slotSpec.name === "plugins.bundle.config",
|
|
1474
1618
|
"count=" + registered.length + " " + JSON.stringify(registered.map((r) => r.slotSpec.name)));
|
|
1475
1619
|
ok("F7b registration is gated on whileServed(entry id)",
|
|
1476
1620
|
Array.isArray(servedNamespaces) && servedNamespaces.length === 1 &&
|
|
1477
1621
|
servedNamespaces[0] === "dsh-web-icon-indicator",
|
|
1478
1622
|
JSON.stringify(servedNamespaces));
|
|
1479
1623
|
const slotSpec = registered[0] && registered[0].slotSpec;
|
|
1480
|
-
eq("F8 slot key is
|
|
1624
|
+
eq("F8 bundle slot key is the bundle package name", slotSpec && slotSpec.key, "dsh-web-icon-indicator");
|
|
1625
|
+
ok("F8b the row slot stays untouched where the bundle slot exists",
|
|
1626
|
+
registered.every((r) => r.slotSpec.name !== "plugins.row.config"),
|
|
1627
|
+
JSON.stringify(registered.map((r) => r.slotSpec.name)));
|
|
1481
1628
|
eq("F9 slot locale namespace", slotSpec && slotSpec.locale, "dsh-web-icon-indicator");
|
|
1482
1629
|
// The scope is resolved lazily inside the inject face (the two settings
|
|
1483
1630
|
// providers are mutually exclusive), so evaluate it before asserting.
|
|
@@ -1530,6 +1677,99 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
|
1530
1677
|
ok("F61f legacy component renders the card", !!legacyCard && legacyCard.type === "div");
|
|
1531
1678
|
}
|
|
1532
1679
|
|
|
1680
|
+
// ---- the row-only Plugins page (DSH 0.1.6-alpha.2) ------------------------
|
|
1681
|
+
// The bundle slot arrived with the 0.1.7 Plugins page. A host that declares
|
|
1682
|
+
// only `plugins.row.config` keeps that older entry point — the configure
|
|
1683
|
+
// control on the row the bundle declares — and must still get exactly one
|
|
1684
|
+
// card, keyed by the row slot's `<package>#<row id>`.
|
|
1685
|
+
{
|
|
1686
|
+
const rowSink = [];
|
|
1687
|
+
const rowCtx = makeClientCtx({
|
|
1688
|
+
declared: ["plugins.row.config"],
|
|
1689
|
+
sink: rowSink,
|
|
1690
|
+
services: {
|
|
1691
|
+
configForms: {
|
|
1692
|
+
get: () => scope,
|
|
1693
|
+
whileServed: (namespaces, register) => register(new Set(namespaces)),
|
|
1694
|
+
},
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
client.apply(rowCtx);
|
|
1698
|
+
eq("F66 row-only page: exactly one registration", rowSink.length, 1);
|
|
1699
|
+
eq("F66b row-only page: registered into plugins.row.config",
|
|
1700
|
+
rowSink[0] && rowSink[0].slotSpec.name, "plugins.row.config");
|
|
1701
|
+
eq("F66c row-only page: key is <package>#<row id>",
|
|
1702
|
+
rowSink[0] && rowSink[0].slotSpec.key, "dsh-web-icon-indicator#dsh-web-icon-indicator");
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// ---- both modern slots, declared in either order --------------------------
|
|
1706
|
+
// The real 0.1.7 host declares `plugins.bundle.config` and
|
|
1707
|
+
// `plugins.row.config` from the Plugins page's own registration, so both
|
|
1708
|
+
// waits this bundle installs resolve AFTER apply(). Whichever declaration
|
|
1709
|
+
// lands first, exactly one entry must survive — and it must be the bundle
|
|
1710
|
+
// one, or the settings would sit a level deeper than the official plugins'.
|
|
1711
|
+
{
|
|
1712
|
+
const makeLazyCtx = (sink) => {
|
|
1713
|
+
const pending = new Map();
|
|
1714
|
+
const ctx = {
|
|
1715
|
+
get: (name) => (name === "configForms"
|
|
1716
|
+
? { get: () => scope, whileServed: (_ns, register) => register(new Set()) }
|
|
1717
|
+
: undefined),
|
|
1718
|
+
locale: { register: () => () => {}, bind: () => (k) => k },
|
|
1719
|
+
slots: {
|
|
1720
|
+
inject(name, cb) {
|
|
1721
|
+
const controller = { cb, active: true, disposers: [] };
|
|
1722
|
+
if (!pending.has(name)) pending.set(name, []);
|
|
1723
|
+
pending.get(name).push(controller);
|
|
1724
|
+
return () => {
|
|
1725
|
+
if (!controller.active) return;
|
|
1726
|
+
controller.active = false;
|
|
1727
|
+
for (const dispose of controller.disposers.splice(0)) dispose();
|
|
1728
|
+
};
|
|
1729
|
+
},
|
|
1730
|
+
register(slotSpec, component) {
|
|
1731
|
+
const entry = { slotSpec, component, disposed: false };
|
|
1732
|
+
sink.push(entry);
|
|
1733
|
+
return () => { entry.disposed = true; };
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
effect: (fn) => fn(),
|
|
1737
|
+
};
|
|
1738
|
+
return {
|
|
1739
|
+
ctx,
|
|
1740
|
+
declare(name) {
|
|
1741
|
+
for (const controller of pending.get(name) || []) {
|
|
1742
|
+
if (!controller.active) continue;
|
|
1743
|
+
const dispose = controller.cb();
|
|
1744
|
+
if (typeof dispose === "function") controller.disposers.push(dispose);
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
const rowFirstSink = [];
|
|
1751
|
+
const rowFirst = makeLazyCtx(rowFirstSink);
|
|
1752
|
+
client.apply(rowFirst.ctx);
|
|
1753
|
+
rowFirst.declare("plugins.row.config");
|
|
1754
|
+
eq("F67 row declaration first: the row entry is registered", rowFirstSink.length, 1);
|
|
1755
|
+
eq("F67b ... into plugins.row.config", rowFirstSink[0].slotSpec.name, "plugins.row.config");
|
|
1756
|
+
rowFirst.declare("plugins.bundle.config");
|
|
1757
|
+
const survivors = rowFirstSink.filter((entry) => !entry.disposed);
|
|
1758
|
+
eq("F67c the bundle declaration takes the card over — one live entry", survivors.length, 1);
|
|
1759
|
+
eq("F67d ... and it is the bundle entry",
|
|
1760
|
+
survivors[0] && survivors[0].slotSpec.name, "plugins.bundle.config");
|
|
1761
|
+
eq("F67e ... keyed by the bundle package name",
|
|
1762
|
+
survivors[0] && survivors[0].slotSpec.key, "dsh-web-icon-indicator");
|
|
1763
|
+
|
|
1764
|
+
const bundleFirstSink = [];
|
|
1765
|
+
const bundleFirst = makeLazyCtx(bundleFirstSink);
|
|
1766
|
+
client.apply(bundleFirst.ctx);
|
|
1767
|
+
bundleFirst.declare("plugins.bundle.config");
|
|
1768
|
+
bundleFirst.declare("plugins.row.config");
|
|
1769
|
+
eq("F68 bundle declaration first: exactly one entry", bundleFirstSink.length, 1);
|
|
1770
|
+
eq("F68b ... the bundle one", bundleFirstSink[0].slotSpec.name, "plugins.bundle.config");
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1533
1773
|
// ---- card render ---------------------------------------------------------
|
|
1534
1774
|
// React is stubbed, so a re-render is driven explicitly: the card's own
|
|
1535
1775
|
// setState calls `rerender()`, which re-invokes the component with the same
|
|
@@ -2258,8 +2498,62 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
|
2258
2498
|
CLIENT_SRC.indexOf("settingsScope") !== -1 &&
|
|
2259
2499
|
CLIENT_SRC.indexOf("settings.plugin.item") !== -1 &&
|
|
2260
2500
|
CLIENT_SRC.indexOf("configForms") !== -1 &&
|
|
2501
|
+
CLIENT_SRC.indexOf("plugins.bundle.config") !== -1 &&
|
|
2261
2502
|
CLIENT_SRC.indexOf("plugins.row.config") !== -1 &&
|
|
2262
2503
|
CLIENT_SRC.indexOf("whileServed") !== -1);
|
|
2504
|
+
|
|
2505
|
+
// ---- official Plugins page icon (host manifest contract) ---------------
|
|
2506
|
+
// The 0.1.7 page reads `package.json.icon` through <pkg>/package.json and
|
|
2507
|
+
// base64s the file (readPluginMeta -> iconOf in @deepseek-ai/dsh-app-boot):
|
|
2508
|
+
// a manifest-relative SVG/PNG/JPEG/WebP at most 256 KiB, contained in the
|
|
2509
|
+
// manifest directory after realpath. Any violation does not blank the card
|
|
2510
|
+
// — it turns the plugin's `meta.error` on and the card shows a problem tag,
|
|
2511
|
+
// so every rule below is load-bearing.
|
|
2512
|
+
const MANIFEST = JSON.parse(readFileSync(new URL("package.json", REPO), "utf8"));
|
|
2513
|
+
const ICON_REL = MANIFEST.icon;
|
|
2514
|
+
const ICON_EXT = typeof ICON_REL === "string" ? ICON_REL.slice(ICON_REL.lastIndexOf(".")).toLowerCase() : "";
|
|
2515
|
+
ok("F62 the manifest declares a relative image icon",
|
|
2516
|
+
typeof ICON_REL === "string" && ICON_REL.trim() !== "" &&
|
|
2517
|
+
!/^[A-Za-z][A-Za-z\d+.-]*:/u.test(ICON_REL) && // rejects URLs (and Windows drive letters)
|
|
2518
|
+
!ICON_REL.startsWith("/") && !ICON_REL.startsWith("\\") &&
|
|
2519
|
+
!/^[A-Za-z]:/u.test(ICON_REL) && // rejects C:\... absolute paths
|
|
2520
|
+
!ICON_REL.split(/[\\/]/u).includes("..") && // must stay inside the manifest directory
|
|
2521
|
+
[".svg", ".png", ".jpg", ".jpeg", ".webp"].includes(ICON_EXT),
|
|
2522
|
+
JSON.stringify(ICON_REL));
|
|
2523
|
+
|
|
2524
|
+
let ICON_SVG = "";
|
|
2525
|
+
let ICON_BYTES = 0;
|
|
2526
|
+
try {
|
|
2527
|
+
ICON_SVG = readFileSync(new URL(ICON_REL, REPO), "utf8");
|
|
2528
|
+
ICON_BYTES = statSync(new URL(ICON_REL, REPO)).size;
|
|
2529
|
+
} catch {
|
|
2530
|
+
/* the F63 check below reports the missing file */
|
|
2531
|
+
}
|
|
2532
|
+
ok("F63 the declared icon ships and stays under the 256 KiB manifest bound",
|
|
2533
|
+
ICON_BYTES > 0 && ICON_BYTES <= 256 * 1024,
|
|
2534
|
+
ICON_BYTES + " bytes");
|
|
2535
|
+
|
|
2536
|
+
// A leftover __COLOR__ placeholder is not valid SVG: the page would render
|
|
2537
|
+
// an unpainted whale (or nothing) where the artwork goes.
|
|
2538
|
+
ok("F64 the icon is standalone SVG with no placeholder left in it",
|
|
2539
|
+
ICON_SVG !== "" &&
|
|
2540
|
+
ICON_SVG.startsWith("<svg ") && ICON_SVG.trimEnd().endsWith("</svg>") &&
|
|
2541
|
+
!ICON_SVG.includes("__COLOR__"),
|
|
2542
|
+
ICON_SVG.slice(0, 40));
|
|
2543
|
+
|
|
2544
|
+
// icons/plugin.svg and docs/assets/favicon.svg are the same static whale;
|
|
2545
|
+
// keep them byte-identical so regenerating one from icons/base.svg cannot
|
|
2546
|
+
// silently leave the other stale. Repo-only (absent from the npm tarball).
|
|
2547
|
+
let DOCS_FAVICON = null;
|
|
2548
|
+
try {
|
|
2549
|
+
DOCS_FAVICON = readFileSync(new URL("docs/assets/favicon.svg", REPO), "utf8");
|
|
2550
|
+
} catch {
|
|
2551
|
+
/* published tarball: docs/ is repo-only */
|
|
2552
|
+
}
|
|
2553
|
+
if (DOCS_FAVICON !== null) {
|
|
2554
|
+
ok("F65 icons/plugin.svg stays identical to docs/assets/favicon.svg",
|
|
2555
|
+
DOCS_FAVICON === ICON_SVG);
|
|
2556
|
+
}
|
|
2263
2557
|
}
|
|
2264
2558
|
}
|
|
2265
2559
|
|