dsh-background-by-model 0.9.0 → 0.9.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
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- > The full release history lives here; the **Recent optimizations** section of [README.md](./README.md#recent-optimizations) lists only the two most recent releases (v0.9.0 / v0.8.2).
3
+ > The full release history lives here; the **Recent optimizations** section of [README.md](./README.md#recent-optimizations) lists only the two most recent releases (v0.9.2 / v0.9.1).
4
+
5
+ ## v0.9.2
6
+
7
+ - **The Config tab reports how many unreferenced images there are, and clears them** — images are stored at full fidelity, so an interrupted upload, a failed file deletion or a wholesale replacement can leave a `modelbg-<slot>` file that no rule references. That residue is invisible by construction: boot reads the slots the config names, so an orphaned file is never loaded, never painted and never reported anywhere.
8
+ - **Read first, delete second** — the card prints how much is cleanable and how many files that is, and only then offers the button; the figures describe what the press would free, not the store's total (a total file count reads as "delete all eight"). The sweep re-reads afterwards rather than subtracting, so a file that failed to delete still counts, and the toast names any that resisted.
9
+ - **The sweep asks nothing** — one press deletes. What goes is by definition referenced by no rule, and a "confirm?" here only teaches people to click through; the safety moved into the **gate** (an unreadable config disables the button). The numbers are still read first — announcing a size after deleting is the cleanup nobody would agree to.
10
+ - **A `modelbg-h-*` file is prunable** — that slot belongs to no config (the festival art lives in `holiday-cache/`, and the slot is read-only), so this is the only path that can ever remove one. `referencedSlots` deliberately takes RULES only, and `check:store` pins the call site so a future edit cannot add the holiday entries back.
11
+ - **An older host reads as "unknown", not "0 B"** — the node half answers two new methods (`storeUsage`, `pruneOrphans`), and a bundle talking to a node half from before them shows "restart DSH" instead of claiming a measurement nobody made.
12
+ - **`check:store`** — the two decisions (`referencedSlots` / `orphanSlots`) on plain node: a picture in use is never an orphan, `keep` only ever spares a slot (the gap between an upload's bytes and the rule entry that names them), and the client sends rule slots only. `check:node` covers the sweep through the real RPC handler.
13
+ - **The READMEs catch up with the checks** — they had been claiming ten since v0.8.1's `check:watchdogs` and `check:scheme` landed; both are now listed, as is `check:store`.
14
+
15
+ ## v0.9.1
16
+
17
+ - **The wallpaper layer's three sliders are one slider with a picker beside it** — opacity, brightness and blur share the row, the menu reads **Image opacity / Image brightness / Image blur**, and switching swaps the range, unit and written field together.
18
+ - **The picker uses the button fill and size, and the hint under the slider is gone.**
4
19
 
5
20
  ## v0.9.0
6
21
 
package/CHANGELOG.zh.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # 更新日志
2
2
 
3
- > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.9.0 / v0.8.2)。
3
+ > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.9.2 / v0.9.1)。
4
+
5
+ ## v0.9.2
6
+
7
+ - **「配置」页会报出有多少没人引用的图片,并把它们清掉** —— 图片按原图保存,所以一次被中断的上传、一次失败的文件删除或一次整批替换,都可能在磁盘上留下一个没有任何规则引用的 `modelbg-<slot>` 文件。这种残留天生不可见:启动只读配置里点名的槽位,所以孤儿文件从来不会被加载、被画出、也不会出现在任何地方。
8
+ - **先读、后删** —— 卡片先显示「可清理多少 MB / 几个文件」,然后才给出按钮;数字只描述这一按会释放什么(不是「正在使用多少」或总共几个文件 —— 后者会被读成「八个都要删」)。清理完重新读一次而不是用减法,所以没删掉的文件仍然算在里面,提示里也会点名是哪几个。
9
+ - **清理不问确认** —— 点一下就删。要删的文件按定义不被任何规则引用,而「确认」在这里只教人不看就点;安全性改放在**前置判断**上:配置读不出来时按钮是灰的。数字仍然是先读的 —— 先删后报数才是没人会同意的那个操作。
10
+ - **`modelbg-h-*` 可以被清掉** —— 那个槽位不属于任何配置(节日画在 `holiday-cache/`,槽位只读),所以这是唯一能删掉它的路径。`referencedSlots` 刻意只接**规则**,`check:store` 钉住调用点,免得以后的改动把节日条目加回去。
11
+ - **旧版宿主编译出来是「读不到」,不是「0 B」** —— node 半边新增两个方法(`storeUsage`、`pruneOrphans`),而对着旧 node 半边的浏览器半边会显示「重启 DSH」,而不是报一个没人量过的数字。
12
+ - **`check:store`** —— 两个判定(`referencedSlots` / `orphanSlots`)在纯 node 上跑:正在用的图永远不会是孤儿、`keep` 只可能**放过**槽位(上传的字节落地与命名它的规则条目之间的那段空隙)、以及客户端只送规则槽位。`check:node` 通过真实 RPC 处理器覆盖清理本身。
13
+ - **两份 README 追上校验的条数** —— 从 v0.8.1 的 `check:watchdogs` 与 `check:scheme` 起它们就一直写着「十道」;现在都列出来了,`check:store` 也在内。
14
+
15
+ ## v0.9.1
16
+
17
+ - **壁纸层的三条滑块合并成一条,旁边用下拉框切换调节项** —— 透明度、亮度与模糊共用同一行,选项改叫「图片透明度 / 图片亮度 / 图片模糊」,切换时范围、单位与写回的字段一起换。
18
+ - **下拉框沿用按钮的底色与尺寸,滑块下方的说明文字删掉。**
4
19
 
5
20
  ## v0.9.0
6
21
 
package/README.md CHANGED
@@ -116,7 +116,7 @@ One switch on the Config page and nothing else: on 中秋节 and 国庆节 the w
116
116
  - **Theme color** — HSL wheel, numeric input, **Extract from this image** and an eyedropper. It belongs to the **image**, and an image with none follows the system theme rather than inheriting its rule's.
117
117
  - **Layout mode** — fit / fill / stretch / tile / center, stored per image; disabled on a rule that holds no picture, rather than hidden.
118
118
  - **Framing** — drag to pan and scroll to zoom, editable in fit mode only, stored per image and stable across window resizes.
119
- - **Background opacity** `0–100%`, **background brightness** `0–200%` (`100%` is the picture as saved) and **background blur** `0–60 px`, all three per rule. They treat the wallpaper layer only — the Interface tab's opacity treats interface surfaces.
119
+ - **Image opacity** `0–100%`, **image brightness** `0–200%` (`100%` is the picture as saved) and **image blur** `0–60 px`, all three per rule and edited through **one slider with a menu beside it** that picks the effect. They treat the image (wallpaper) layer only — the Interface tab's opacity treats interface surfaces.
120
120
 
121
121
  ### Global settings (Interface tab)
122
122
 
@@ -130,9 +130,20 @@ One click on the Config tab downloads the author's own configuration — rules,
130
130
 
131
131
  - **Hosted in the store's own shape** (`preset/preset.json`, `preset/theme-config.json`, one `modelbg-<slot>` file per wallpaper), so it stays editable on GitHub and publishing it is a `cp`.
132
132
  - **Every interface number in it is what the plugin already ships with**, so on a new install the button adds rules and pictures; on a store you have already tuned it resets those too, because it is a whole configuration and not a patch.
133
- - **The download comes before the question**, so the confirmation can say what is actually in hand — and the wipe comes after the download, never before it. It is the only destructive action in the panel.
133
+ - **The download comes before the question**, so the confirmation can say what is actually in hand — and the wipe comes after the download, never before it. It is the only card in the panel that **asks before it deletes**.
134
134
  - **One missing wallpaper fails the whole attempt and names the slot**, and every file retries on its own, keeping what arrived for the next press.
135
135
 
136
+ ### Image storage cleanup
137
+
138
+ The last card on the Config tab. Images are stored at **full fidelity**, so an interrupted upload, a failed file deletion or a wholesale config replacement can leave a file on disk that no rule references any more — and on a tidy machine that number is normally **0**. The card reads the numbers, and the button deletes **without asking**.
139
+
140
+ - **Read first, delete second** — it shows how much is cleanable and how many files that is, before it offers the button. A cleanup whose size is announced afterwards is a cleanup nobody agrees to. The numbers describe only **what the press would free**: "2.1 MB in use" answers a question nobody asked on this card, and a total file count reads as "eight to delete" when the honest answer is one.
141
+ - **Only unreferenced files** — every byte a current rule uses is untouched, and the config itself is never written. The card re-reads after the sweep instead of subtracting, so a file that failed to delete still counts.
142
+ - **Failures are named** — one undeletable file does not protect the other six, and it is not reported as success either: the toast lists which ones.
143
+ - **No confirmation step** — what it deletes is by definition referenced by no rule, and is not a choice made from a working set, so a "confirm?" here only teaches people to click through. The safety is in the **gate**: when the config cannot be read the button is disabled (next bullet), rather than seven files being listed and then asked about.
144
+ - **It also clears a stray `modelbg-h-*`** — that slot belongs to no config (festival art lives in `holiday-cache/`, and the slot is read-only), so this is the only path that can ever remove such a file.
145
+ - **An older host reads as "unknown", not "0 B"** — the two nothings must not look the same.
146
+
136
147
  ## Settings
137
148
 
138
149
  Settings → **Theme** now has three tabs:
@@ -141,7 +152,7 @@ Settings → **Theme** now has three tabs:
141
152
  | --- | --- | --- |
142
153
  | **Interface** | Global, shared by every model | Opacity for the main background, left panel, right panel, cards & panels, input & controls, settings panel, conversation text box, trajectory page and every plugin page |
143
154
  | **Model Background** | Per rule, per image | The ordered rule list, the live match readout, and each rule's images (filmstrip), **per-image layout mode, framing and theme color**, plus the rule's opacity, brightness, blur and [rotation](#image-rotation) |
144
- | **Config** | — | The global [switch effect](#switch-effect-global), the whole rule set as a file — [recommended profile](#recommended-profile), import and export (`dsh-background-by-model-theme.json`) — plus the single [holiday background](#holiday-backgrounds) switch |
155
+ | **Config** | — | The global [switch effect](#switch-effect-global), the whole rule set as a file — [recommended profile](#recommended-profile), import and export (`dsh-background-by-model-theme.json`) — plus the single [holiday background](#holiday-backgrounds) switch and the [image storage cleanup](#image-storage-cleanup) |
145
156
 
146
157
  The old **Color** tab is gone — the theme color is now a property of each rule — and the old **Background** tab became **Model Background**.
147
158
 
@@ -184,7 +195,7 @@ npx @deepseek-ai/dsh web
184
195
  git clone https://github.com/HarmlessFunny/dsh-background-by-model.git
185
196
  cd dsh-background-by-model
186
197
  pnpm install
187
- pnpm test # builds lib/, typechecks, runs the ten checks
198
+ pnpm test # builds lib/, typechecks, runs the thirteen checks
188
199
  ```
189
200
 
190
201
  `pnpm run bundle` rebuilds `lib/` on its own. Either has to run before the plugin can load: a fresh clone has no `lib/`.
@@ -206,7 +217,7 @@ To mount a working copy into a profile instead, add it to the profile's `package
206
217
 
207
218
  After changing anything under `src/`, run `pnpm run bundle` again — the mounted profile loads `lib/`, so edits do not take effect until the bundle is rebuilt.
208
219
 
209
- `pnpm test` builds, typechecks and runs ten checks, none of which needs a dependency, a transformer or a browser. Each can also be run on its own with `pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:code-plate` / `pnpm check:image-mode` / `pnpm check:rules` / `pnpm check:preset` / `pnpm check:ui` / `pnpm check:node`:
220
+ `pnpm test` builds, typechecks and runs thirteen checks, none of which needs a dependency, a transformer or a browser. Each can also be run on its own with `pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:code-plate` / `pnpm check:image-mode` / `pnpm check:rules` / `pnpm check:preset` / `pnpm check:ui` / `pnpm check:watchdogs` / `pnpm check:scheme` / `pnpm check:store` / `pnpm check:node`:
210
221
 
211
222
  - **`check:holiday`** — the two holiday windows, the Beijing day boundary, leap eighth months across 1900–2100, and the four gates `pickHoliday` applies.
212
223
  - **`check:rotation`** — `nextIndex` / `isRotating`: no walking off the end, shuffle never repeating the image on screen, and nothing called "rotating" with nothing to rotate.
@@ -217,7 +228,10 @@ After changing anything under `src/`, run `pnpm run bundle` again — the mounte
217
228
  - **`check:rules`** — the two decisions behind the tab strip, which live in `src/client/rules-view.ts`, plus the page structure the layout rests on.
218
229
  - **`check:preset`** — the recommended profile's retry policy and what a failed attempt holds for the next one, with both halves shown using them.
219
230
  - **`check:ui`** — both dictionaries carrying the same keys, every `dab-…` class styled, no dead entry, and every `var(--dsw-…)` published by the installed theme.
220
- - **`check:node`** — the built node half through its real RPC handler with `DSH_HOME` redirected and `fetch` stubbed: the holiday block, the festival art's caching and pruning, mirror fallback, the per-image color lift, the global switch effect, and the recommended profile end to end.
231
+ - **`check:watchdogs`** — one heartbeat, entries firing on their own due time, paused while the tab is hidden, no timer left by dispose, and no module outside the entry starting its own interval.
232
+ - **`check:scheme`** — the `data-ds-dark-theme` hand-off: the plugin only rewrites the value it wrote, hands the flag back when it drops its tokens, and repaints when the scheme moves.
233
+ - **`check:store`** — the two decisions behind the storage cleanup, `referencedSlots` and `orphanSlots`: a picture in use is never an orphan, a stray holiday-slot file always is, and the browser half sends RULE slots rather than the whole config.
234
+ - **`check:node`** — the built node half through its real RPC handler with `DSH_HOME` redirected and `fetch` stubbed: the holiday block, the festival art's caching and pruning, mirror fallback, the per-image color lift, the global switch effect, the store's usage report and cleanup, and the recommended profile end to end.
221
235
 
222
236
  ## FAQ
223
237
 
@@ -270,6 +284,15 @@ The effect is **Instant**, or the duration is `0` — an effect and a duration a
270
284
 
271
285
  > Only the two most recent releases are listed here; older ones live in [CHANGELOG.md](./CHANGELOG.md).
272
286
 
287
+ ### v0.9.2
288
+
289
+ - **The Config tab gains an "Image storage" card** — it reports how much is cleanable and how many files that is, then offers the button; only unreferenced files go, the config and the festival art stay, and any file that resists is named.
290
+ - **`check:store`, so there are thirteen checks now** — both READMEs had been claiming ten, having missed `check:watchdogs` and `check:scheme` since v0.8.1; they are listed again alongside the new one.
291
+
292
+ ### v0.9.1
293
+
294
+ - **The three wallpaper sliders are now one slider plus a picker** — the options read Image opacity / Image brightness / Image blur, the picker matches the buttons, and the hint below is gone.
295
+
273
296
  ### v0.9.0
274
297
 
275
298
  - **The RPC channel is the plugin's own prefix route on `webServer`**, so envelope, body cap and the auth fence stay here and `requestRejection` is asked per request.
@@ -277,12 +300,7 @@ The effect is **Instant**, or the duration is `0` — an effect and a duration a
277
300
  - **The browser entry point is wiring again** — skin, watchdogs, rotation, boot, model state, extraction and theme files are their own modules.
278
301
  - **Exporting a theme no longer cancels its own download**, and comments say why the code is what it is rather than what it used to be.
279
302
 
280
- ### v0.8.2
281
-
282
- - **The dsh dependency floor follows the host** — `engines.dsh`, the five `dsh-client-*` peers and `dsh-home-paths` now declare `>=0.2.1-alpha.2` instead of the 0.1.7 generation, so an install cannot be paired with the previous client packages, while `dsh-invariants` and `@deepseek-ai/cordis` stay as they are.
283
-
284
303
  ## Star History
285
-
286
304
  [![Star History Chart](https://api.star-history.com/chart?repos=HarmlessFunny/dsh-background-by-model&type=timeline&legend=bottom-right)](https://www.star-history.com/?repos=HarmlessFunny%2Fdsh-background-by-model&type=timeline&legend=bottom-right)
287
305
 
288
306
  ## License
package/README.zh.md CHANGED
@@ -116,7 +116,7 @@
116
116
  - **主题色** —— HSL 色轮、数值输入、**从本图提取**与吸管。它属于**图片**,没有自己颜色的图跟随系统主题,而不是继承规则色。
117
117
  - **布局模式** —— 适应 / 填充 / 拉伸 / 平铺 / 居中,逐图保存;规则没有图片时这排是禁用而不是隐藏。
118
118
  - **取景** —— 拖动平移、滚轮缩放,只在适应模式下可编辑,逐图保存,窗口缩放后保持一致。
119
- - **背景透明度** `0–100%`、**背景亮度** `0–200%`(`100%` 即原图)与**背景模糊** `0–60 px`,三者都按规则。它们只作用于壁纸层;「界面」页的透明度作用于界面各区域。
119
+ - **图片透明度** `0–100%`、**图片亮度** `0–200%`(`100%` 即原图)与**图片模糊** `0–60 px`,三者都按规则,并通过**同一条滑块 + 旁边的下拉框**切换调节项。它们只作用于图片层;「界面」页的透明度作用于界面各区域。
120
120
 
121
121
  ### 全局设置(「界面」页)
122
122
 
@@ -130,9 +130,20 @@
130
130
 
131
131
  - **托管在数据目录自己的形状里**(`preset/preset.json`、`preset/theme-config.json`,每张壁纸一个 `modelbg-<slot>` 文件),所以它能在 GitHub 上直接编辑,发布它就是一次 `cp`。
132
132
  - **里面每一个界面数字都是插件本来就用着的值**,所以在新装上是「加上规则和图片」;而在你已经调过的数据目录上,它把这些也一起重置 —— 它是一整份配置,不是一个补丁。
133
- - **先下载、后提问**,所以确认框能如实说出手里有什么 —— 而清空在下载**之后**,绝不在之前。它是面板里唯一会销毁东西的操作。
133
+ - **先下载、后提问**,所以确认框能如实说出手里有什么 —— 而清空在下载**之后**,绝不在之前。它是面板里唯一一张会**先问再删**的卡片。
134
134
  - **少一张壁纸就整体失败并报出是哪个槽位**,而且每个文件各自重试,已经下到的部分留给下一次。
135
135
 
136
+ ### 图片存储清理
137
+
138
+ 「配置」页最后一张卡片:图片是按**原图**保存的,所以一次被中断的上传、一次失败的文件删除,或一次整套配置的替换,都可能在磁盘上留下不再被任何规则引用的文件 —— 而这台机器上「孤儿」正常是 **0**。卡片把用量读出来,**点清理就直接删**。
139
+
140
+ - **先读、后删** —— 卡片先显示「可清理多少 MB / 几个文件」,再给出清理按钮;先删再报数的操作没人会同意。数字只描述**这一按会释放什么**:显示「正在使用 2.1 MB」是在回答一个没人问的问题,而显示「8 个文件」会让人以为八张全要删掉。
141
+ - **只删没人引用的** —— 当前规则用到的图一个字节都不动,配置本身也不写。清理完会重新读一次,而不是用减法算,所以删除失败的文件仍然算在里面。
142
+ - **失败会点名** —— 一个文件删不掉不会让其余六个留下来,但也不会被说成「已清理」:提示里会列出是哪几个。
143
+ - **不问确认** —— 要删的东西按定义没有任何规则引用,而且不是从一个能用的集合里挑出来的,所以「确认」在这里只教人不看就点。安全性放在**前置判断**上:配置读不出来时按钮是灰的(见下一条),而不是先把七个文件列出来再问一遍。
144
+ - **顺手清掉误入槽位的节日文件** —— `modelbg-h-*` 不属于任何配置(节日画在 `holiday-cache/` 里,那个槽位只读),所以它是唯一一个只有这条路径能清掉的残留。
145
+ - **宿主是旧版时显示「读不到」而不是「0 B」** —— 两种「没有」必须长得不一样。
146
+
136
147
  ## 设置界面
137
148
 
138
149
  设置的**「主题」**分类下现在有三个选项卡:
@@ -141,7 +152,7 @@
141
152
  | --- | --- | --- |
142
153
  | **界面** | 全局,所有模型共用 | 主背景、左侧栏、右侧栏、卡片面板、输入框与控件、设置面板、对话文本框、轨迹页与每一个插件页的透明度 |
143
154
  | **模型背景** | 每条规则、每张图片 | 有序规则列表、实时匹配状态,以及每条规则的图片(胶片条)、**逐图布局模式 / 取景 / 主题色**,加上规则自己的透明度、亮度、模糊度与[多图轮换](#多图轮换) |
144
- | **配置** | — | 全局[切换效果](#切换效果全局)、整套规则的进出 —— [推荐配置](#推荐配置)与导入 / 导出(`dsh-background-by-model-theme.json`)—— 外加[节日特殊背景](#节日特殊背景)那一个开关 |
155
+ | **配置** | — | 全局[切换效果](#切换效果全局)、整套规则的进出 —— [推荐配置](#推荐配置)与导入 / 导出(`dsh-background-by-model-theme.json`)—— 外加[节日特殊背景](#节日特殊背景)那一个开关,以及[图片存储清理](#图片存储清理) |
145
156
 
146
157
  原先的**「色彩」**选项卡已删除 —— 主题色改为规则与图片各自的属性 —— 原**「背景」**选项卡改造成**「模型背景」**。
147
158
 
@@ -184,7 +195,7 @@ npx @deepseek-ai/dsh web
184
195
  git clone https://github.com/HarmlessFunny/dsh-background-by-model.git
185
196
  cd dsh-background-by-model
186
197
  pnpm install
187
- pnpm test # 构建 lib/、类型检查、跑十道检查
198
+ pnpm test # 构建 lib/、类型检查、跑十三道检查
188
199
  ```
189
200
 
190
201
  `pnpm run bundle` 只重建 `lib/`。两者都必须在插件能加载之前先跑一次:新 clone 出来没有 `lib/`。
@@ -206,7 +217,7 @@ pnpm test # 构建 lib/、类型检查、跑十道检查
206
217
 
207
218
  改完 `src/` 必须重新执行 `pnpm run bundle` 才会生效 —— 挂载的 profile 加载的是 `lib/`。
208
219
 
209
- `pnpm test` 会构建、类型检查并跑十道校验,十道都不需要依赖、不需要转译器、也不需要浏览器。也可以单独跑:`pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:code-plate` / `pnpm check:image-mode` / `pnpm check:rules` / `pnpm check:preset` / `pnpm check:ui` / `pnpm check:node`:
220
+ `pnpm test` 会构建、类型检查并跑十三道校验,十三道都不需要依赖、不需要转译器、也不需要浏览器。也可以单独跑:`pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:code-plate` / `pnpm check:image-mode` / `pnpm check:rules` / `pnpm check:preset` / `pnpm check:ui` / `pnpm check:watchdogs` / `pnpm check:scheme` / `pnpm check:store` / `pnpm check:node`:
210
221
 
211
222
  - **`check:holiday`** —— 两个节日窗口、北京时间的跨日边界、1900–2100 年间的闰八月,以及 `pickHoliday` 的四道闸门。
212
223
  - **`check:rotation`** —— `nextIndex` / `isRotating`:不走越界、随机不重复当前这张、没有可轮换的东西时绝不报「正在轮换」。
@@ -217,7 +228,10 @@ pnpm test # 构建 lib/、类型检查、跑十道检查
217
228
  - **`check:rules`** —— 选项卡背后那两个住在 `src/client/rules-view.ts` 的判定,以及布局所依赖的页面结构。
218
229
  - **`check:preset`** —— 推荐配置的重试策略与失败那一次给下一次留下什么,并验证两侧真的在用它们。
219
230
  - **`check:ui`** —— 两本字典键集一致、每个 `dab-…` 类都有样式、没有死键,以及每个 `var(--dsw-…)` 都是已安装主题发布过的 token。
220
- - **`check:node`** —— 把构建好的 node 半边通过真实的 RPC 处理器跑一遍(`DSH_HOME` 指向临时目录、`fetch` 换成桩):节日配置块、节日画的缓存与清理、镜像回落、逐图颜色抬升、全局切换效果,以及推荐配置的整条链路。
231
+ - **`check:watchdogs`** —— 一条心跳、按各自到期时间触发、隐藏即暂停、dispose 不留定时器,且入口之外没有模块自己起 interval。
232
+ - **`check:scheme`** —— `data-ds-dark-theme` 的交接:插件只改自己写的那个值、清掉 token 时一起交还标记、方案变了会重绘。
233
+ - **`check:store`** —— 存储清理的两个判定:`referencedSlots` 与 `orphanSlots`。钉住「正在用的图不会被当成孤儿」与「节日槽位的文件必须可清」,外加浏览器半边传的是**规则槽位**而不是整份配置。
234
+ - **`check:node`** —— 把构建好的 node 半边通过真实的 RPC 处理器跑一遍(`DSH_HOME` 指向临时目录、`fetch` 换成桩):节日配置块、节日画的缓存与清理、镜像回落、逐图颜色抬升、全局切换效果、图片存储的用量报告与清理,以及推荐配置的整条链路。
221
235
 
222
236
  ## 常见问题
223
237
 
@@ -270,6 +284,15 @@ pnpm test # 构建 lib/、类型检查、跑十道检查
270
284
 
271
285
  > 这里只列最近两个版本;更早的版本见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
272
286
 
287
+ ### v0.9.2
288
+
289
+ - **「配置」页多了一张「图片存储」卡片** —— 先报出可清理多少、涉及几个文件,再给你一个清理按钮;只删没有规则引用的文件,配置与节日画都不动,删不掉的文件会点名。
290
+ - **`check:store`,于是现在十三道校验** —— 两份 README 此前一直写着「十道」,`check:watchdogs` 与 `check:scheme` 从 v0.8.1 起就没列进去,这次一并补上。
291
+
292
+ ### v0.9.1
293
+
294
+ - **三条壁纸滑块合并成一条,旁边用下拉框切换** —— 选项改叫图片透明度 / 图片亮度 / 图片模糊,下拉框与按钮同色,滑块下的说明文字删掉。
295
+
273
296
  ### v0.9.0
274
297
 
275
298
  - **RPC 通道是插件自己在 `webServer` 上的 prefix 路由** —— envelope、请求上限与鉴权围墙留在插件这侧,每个请求都询问 `requestRejection`。
@@ -277,10 +300,6 @@ pnpm test # 构建 lib/、类型检查、跑十道检查
277
300
  - **浏览器半边入口只剩接线** —— 皮肤、看守、轮换、启动、模型状态、取色与主题文件各自成模块。
278
301
  - **导出主题不再取消自己的下载**,注释也只讲代码为什么是现在这样、不讲它曾经是什么样。
279
302
 
280
- ### v0.8.2
281
-
282
- - **dsh 依赖下界跟着宿主走** —— `engines.dsh`、5 个 `dsh-client-*` peer 与 `dsh-home-paths` 从 0.1.7 那一代改成声明 `>=0.2.1-alpha.2`,装出来的插件不会再被配上上一代客户端包,而 `dsh-invariants` 与 `@deepseek-ai/cordis` 各有理由、保持不动。
283
-
284
303
  ## Star History
285
304
 
286
305
  [![Star History Chart](https://api.star-history.com/chart?repos=HarmlessFunny/dsh-background-by-model&type=timeline&legend=bottom-right)](https://www.star-history.com/?repos=HarmlessFunny%2Fdsh-background-by-model&type=timeline&legend=bottom-right)
package/lib/client.js CHANGED
@@ -1167,6 +1167,50 @@ window.__ModuleLoader__.load({
1167
1167
  const parts = [d.provider, d.model].filter((s) => typeof s === "string" && s !== "");
1168
1168
  return parts.length > 0 ? parts.join(" ") : null;
1169
1169
  }
1170
+ /**
1171
+ * What the store occupies and what nothing points at — read-only.
1172
+ *
1173
+ * `keep` is this bundle's live slot list, which the node half unions with the config
1174
+ * on disk (see `storeUsage`): the two are written at different moments, and the
1175
+ * report must never call a file an orphan in the gap between an upload's bytes
1176
+ * landing and its rule entry arriving. Sending the list can therefore only SPARE a
1177
+ * slot, never condemn one.
1178
+ */
1179
+ async function readStoreUsage(keep) {
1180
+ const data = await rpcCall("storeUsage", { keep });
1181
+ if (data === null || typeof data !== "object") return null;
1182
+ const d = data;
1183
+ return {
1184
+ totalBytes: numberOr(d.totalBytes, 0),
1185
+ usedBytes: numberOr(d.usedBytes, 0),
1186
+ orphanBytes: numberOr(d.orphanBytes, 0),
1187
+ files: numberOr(d.files, 0),
1188
+ slots: Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [],
1189
+ orphans: Array.isArray(d.orphans) ? d.orphans.filter((o) => o !== null && typeof o === "object" && typeof o.slot === "string").map((o) => ({
1190
+ slot: o.slot,
1191
+ bytes: numberOr(o.bytes, 0)
1192
+ })) : [],
1193
+ trusted: d.trusted === true
1194
+ };
1195
+ }
1196
+ /**
1197
+ * Delete every stored image nothing points at. The one destructive half of the
1198
+ * pair, and it takes the same `keep` for the same reason.
1199
+ */
1200
+ async function pruneOrphanImages(keep) {
1201
+ const data = await rpcCall("pruneOrphans", { keep });
1202
+ if (data === null || typeof data !== "object") return null;
1203
+ const d = data;
1204
+ return {
1205
+ removed: Array.isArray(d.removed) ? d.removed.filter((s) => typeof s === "string") : [],
1206
+ failed: Array.isArray(d.failed) ? d.failed.filter((s) => typeof s === "string") : [],
1207
+ freedBytes: numberOr(d.freedBytes, 0),
1208
+ refused: d.refused === true
1209
+ };
1210
+ }
1211
+ function numberOr(value, fallback) {
1212
+ return typeof value === "number" && isFinite(value) && value >= 0 ? value : fallback;
1213
+ }
1170
1214
  //#endregion
1171
1215
  //#region src/client/utils/color.ts
1172
1216
  function hsvToHsl(h, s, v) {
@@ -1875,6 +1919,16 @@ window.__ModuleLoader__.load({
1875
1919
  .dab-slider:hover::-webkit-slider-thumb,.dab-slider:focus-visible::-webkit-slider-thumb{transform:scale(1.22)}
1876
1920
  .dab-slider:active::-webkit-slider-thumb{transform:scale(1.34)}
1877
1921
  .dab-slider::-moz-range-thumb{width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-button-elevated-fill,#fff);border:2px solid var(--dsw-alias-brand-primary)}
1922
+ /* The wallpaper effects share one track and one row: the menu that names the
1923
+ target sits BESIDE the track, not above it, so the merged control is no taller
1924
+ than a single slider used to be. The track takes the remaining width and the
1925
+ menu sizes itself to its longest option (which is what keeps the English
1926
+ "Background brightness" from clipping at a width chosen for Chinese). */
1927
+ .dab-wp-row{display:flex;align-items:center;gap:10px}
1928
+ .dab-wp-row .dab-slider-block{flex:1;min-width:0}
1929
+ .dab-select{flex:none;max-width:50%;height:34px;padding:0 10px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12.5px;font-weight:550;outline:none;cursor:pointer;transition:border-color .2s,box-shadow .2s}
1930
+ .dab-select:hover{border-color:var(--dsw-alias-brand-primary)}
1931
+ .dab-select:focus-visible{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
1878
1932
 
1879
1933
  /* ── interface part cards ────────────────────────────────────────────────── */
1880
1934
  .dab-grid-parts{display:grid;grid-template-columns:repeat(auto-fill,minmax(256px,1fr));gap:13px}
@@ -1917,6 +1971,13 @@ window.__ModuleLoader__.load({
1917
1971
  .dab-profile-ico{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;margin-bottom:13px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
1918
1972
  .dab-profile-title{font-size:14px;font-weight:650}
1919
1973
  .dab-profile-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.55;margin:5px 0 15px}
1974
+ /* The image store's three numbers. A row of VALUES with a caption under each,
1975
+ not a sentence: they are read as a measurement ("how much is orphaned"), and a
1976
+ sentence makes the reader hunt for the one number that matters. */
1977
+ .dab-store-stats{display:flex;gap:16px;margin:2px 0 14px}
1978
+ .dab-store-stat{display:flex;flex-direction:column;gap:2px;min-width:0}
1979
+ .dab-store-stat-v{font-size:14px;font-weight:650;font-family:var(--dab-mono);letter-spacing:.01em;color:var(--dsw-alias-label-primary)}
1980
+ .dab-store-stat-k{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1920
1981
  .dab-footer{margin-top:6px;padding:14px 4px 0;border-top:1px solid var(--dsw-alias-border-l2);display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--dsw-alias-label-tertiary)}
1921
1982
  .dab-footer-mono{font-family:var(--dab-mono);letter-spacing:.02em}
1922
1983
  /* Same reset as the brand: the package name links to the repository, and the
@@ -4238,6 +4299,11 @@ window.__ModuleLoader__.load({
4238
4299
  className,
4239
4300
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M7.2 1.8l1.2 3.1 3.1 1.2-3.1 1.2-1.2 3.1-1.2-3.1L2.9 6.1 6 4.9l1.2-3.1z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 10.5l.5 1.2 1.2.5-1.2.5-.5 1.2-.5-1.2-1.2-.5 1.2-.5.5-1.2z" })]
4240
4301
  });
4302
+ const RefreshIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Glyph, {
4303
+ size,
4304
+ className,
4305
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M13.2 8A5.2 5.2 0 1 1 11.5 4.2" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M13.4 1.8v2.9h-2.9" })]
4306
+ });
4241
4307
  const DownloadIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Glyph, {
4242
4308
  size,
4243
4309
  className,
@@ -4683,10 +4749,10 @@ window.__ModuleLoader__.load({
4683
4749
  ruleLayout: "布局模式",
4684
4750
  ruleLayoutNoImage: "添加图片后可分别设置每张图的布局模式",
4685
4751
  ruleFramingEdit: "编辑位置",
4686
- ruleOpacity: "背景透明度",
4687
- ruleBrightness: "背景亮度",
4688
- ruleBlur: "背景模糊",
4689
- ruleBlurHint: "这三条滑块都作用于壁纸层;界面页里的透明度作用于界面各区域,两者互不影响",
4752
+ ruleOpacity: "图片透明度",
4753
+ ruleBrightness: "图片亮度",
4754
+ ruleBlur: "图片模糊",
4755
+ ruleEffectPick: "调节项",
4690
4756
  rotTitle: "多图轮换",
4691
4757
  rotEnable: "轮换这组图",
4692
4758
  rotNeedTwo: "至少两张图才能轮换",
@@ -4757,6 +4823,18 @@ window.__ModuleLoader__.load({
4757
4823
  recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
4758
4824
  recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
4759
4825
  recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
4826
+ storeCardTitle: "图片存储",
4827
+ storeCardDesc: "因意外导致的孤儿图片这里可以清掉",
4828
+ storeOrphans: "可清理",
4829
+ storeFiles: "个可删文件",
4830
+ storeReading: "正在读取…",
4831
+ storeUnknown: "读不到存储信息(宿主进程可能是旧版插件,重启 DSH 后可用)",
4832
+ storeCleanNone: "没有多余的文件",
4833
+ storeClean: "清理",
4834
+ storeDoneFreed: "已清理,释放",
4835
+ storePartial: "有些文件没能删除:",
4836
+ storeRefused: "配置文件读不出来(theme-config.json 解析失败),现在分不清哪些图片还在用,所以没有删除任何东西。修好或删掉那个文件后重启 DSH 再试",
4837
+ storeRefresh: "重新读取",
4760
4838
  footerTag: "按模型换背景插件",
4761
4839
  repoLink: "在 GitHub 上打开仓库",
4762
4840
  holidayTitle: "节日特殊背景",
@@ -4854,10 +4932,10 @@ window.__ModuleLoader__.load({
4854
4932
  ruleLayout: "Layout mode",
4855
4933
  ruleLayoutNoImage: "Add an image to give each one its own layout mode",
4856
4934
  ruleFramingEdit: "Edit position",
4857
- ruleOpacity: "Background opacity",
4858
- ruleBrightness: "Background brightness",
4859
- ruleBlur: "Background blur",
4860
- ruleBlurHint: "These three sliders all treat the wallpaper layer; the opacity on the Interface page affects interface surfaces — they are independent",
4935
+ ruleOpacity: "Image opacity",
4936
+ ruleBrightness: "Image brightness",
4937
+ ruleBlur: "Image blur",
4938
+ ruleEffectPick: "Effect",
4861
4939
  rotTitle: "Image rotation",
4862
4940
  rotEnable: "Rotate these images",
4863
4941
  rotNeedTwo: "At least two images are needed to rotate",
@@ -4928,6 +5006,18 @@ window.__ModuleLoader__.load({
4928
5006
  recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
4929
5007
  recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
4930
5008
  recFailApply: "Could not write the recommended profile — your own is untouched",
5009
+ storeCardTitle: "Image storage",
5010
+ storeCardDesc: "Orphaned image files left behind by an accident can be cleared here",
5011
+ storeOrphans: "Cleanable",
5012
+ storeFiles: "deletable files",
5013
+ storeReading: "Reading…",
5014
+ storeUnknown: "Could not read the store — the host process may be an older build; restart DSH",
5015
+ storeCleanNone: "Nothing to clean up",
5016
+ storeClean: "Clean up",
5017
+ storeDoneFreed: "Cleaned up, freed",
5018
+ storePartial: "Some files could not be deleted: ",
5019
+ storeRefused: "The config file could not be read (theme-config.json does not parse), so nothing on disk can be told apart from an orphan. Nothing was deleted — fix or remove that file and restart DSH",
5020
+ storeRefresh: "Refresh",
4931
5021
  footerTag: "Per-model wallpaper plugin",
4932
5022
  repoLink: "Open the repository on GitHub",
4933
5023
  holidayTitle: "Holiday backgrounds",
@@ -5137,7 +5227,7 @@ window.__ModuleLoader__.load({
5137
5227
  * the canonical default. The track fill is a gradient driven by the --pct
5138
5228
  * custom property, updated imperatively alongside the thumb.
5139
5229
  */
5140
- function LiveSlider({ min, max, step, def, fmt, label, onInput, onChange }) {
5230
+ function LiveSlider({ min, max, step, def, fmt, label, ariaLabel, onInput, onChange }) {
5141
5231
  const inputRef = (0, react.useRef)(null);
5142
5232
  const valRef = (0, react.useRef)(null);
5143
5233
  const paint = (el, v) => {
@@ -5164,6 +5254,7 @@ window.__ModuleLoader__.load({
5164
5254
  onInput?.(v);
5165
5255
  onChange(v);
5166
5256
  };
5257
+ const name = label ?? ariaLabel;
5167
5258
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5168
5259
  className: "dab-slider-block",
5169
5260
  children: [label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -5184,7 +5275,8 @@ window.__ModuleLoader__.load({
5184
5275
  max,
5185
5276
  step,
5186
5277
  defaultValue: def,
5187
- title: label ? `${label} · ${fmt(def)}` : fmt(def),
5278
+ "aria-label": name,
5279
+ title: name !== void 0 ? `${name} · ${fmt(def)}` : fmt(def),
5188
5280
  onDoubleClick: () => apply(def),
5189
5281
  onInput: (e) => {
5190
5282
  const el = e.target;
@@ -6192,6 +6284,7 @@ window.__ModuleLoader__.load({
6192
6284
  ...m,
6193
6285
  [id]: i
6194
6286
  });
6287
+ const [wpField, setWpField] = (0, react.useState)("opacity");
6195
6288
  /** Select a rule as the edited one; keyboard stepping shares this. */
6196
6289
  const selectRule = (id) => {
6197
6290
  pickedRef.current = true;
@@ -6286,6 +6379,8 @@ window.__ModuleLoader__.load({
6286
6379
  notify,
6287
6380
  sel: selIdxOf(rule.id),
6288
6381
  onSel: (i) => setSelIdxOf(rule.id, i),
6382
+ field: wpField,
6383
+ onField: setWpField,
6289
6384
  onRemove: () => removeRule(rule.id)
6290
6385
  }, rule.id);
6291
6386
  })()
@@ -6451,7 +6546,78 @@ window.__ModuleLoader__.load({
6451
6546
  ]
6452
6547
  });
6453
6548
  }
6454
- function RuleCard({ p, rule, index, total, active, liveIndex, notify, sel, onSel, onRemove }) {
6549
+ /**
6550
+ * The rule's three wallpaper effects on ONE row: the menu names the target and a
6551
+ * single track edits it.
6552
+ *
6553
+ * The three ranges are not interchangeable — opacity and brightness are
6554
+ * percentages (brightness ceilings at the shared `WALLPAPER_BRIGHTNESS_MAX`),
6555
+ * blur is pixels — so changing the menu swaps the whole track: limits, formatter
6556
+ * and writer together. It never reinterprets one track under another name. The
6557
+ * `key` remounts the input so `defaultValue` starts at the new field's current
6558
+ * value; LiveSlider's drag-live path then works exactly as it did when the three
6559
+ * tracks were stacked.
6560
+ */
6561
+ function WallpaperEffectRow({ p, rule, field, onField }) {
6562
+ const { t } = p;
6563
+ const spec = field === "opacity" ? {
6564
+ label: t("ruleOpacity"),
6565
+ min: 0,
6566
+ max: 100,
6567
+ step: 1,
6568
+ def: Math.round(rule.wallpaperOpacity * 100),
6569
+ fmt: (v) => `${v}%`,
6570
+ onChange: (v) => p.setRule(rule.id, { wallpaperOpacity: v / 100 })
6571
+ } : field === "brightness" ? {
6572
+ label: t("ruleBrightness"),
6573
+ min: 0,
6574
+ max: 200,
6575
+ step: 1,
6576
+ def: rule.brightness ?? 100,
6577
+ fmt: (v) => `${v}%`,
6578
+ onChange: (v) => p.setRule(rule.id, { brightness: v })
6579
+ } : {
6580
+ label: t("ruleBlur"),
6581
+ min: 0,
6582
+ max: 60,
6583
+ step: 1,
6584
+ def: rule.blur,
6585
+ fmt: (v) => `${v}px`,
6586
+ onChange: (v) => p.setRule(rule.id, { blur: v })
6587
+ };
6588
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6589
+ className: "dab-wp-row",
6590
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
6591
+ className: "dab-select",
6592
+ value: field,
6593
+ "aria-label": t("ruleEffectPick"),
6594
+ onChange: (e) => onField(e.target.value),
6595
+ children: [
6596
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6597
+ value: "opacity",
6598
+ children: t("ruleOpacity")
6599
+ }),
6600
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6601
+ value: "brightness",
6602
+ children: t("ruleBrightness")
6603
+ }),
6604
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6605
+ value: "blur",
6606
+ children: t("ruleBlur")
6607
+ })
6608
+ ]
6609
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
6610
+ ariaLabel: spec.label,
6611
+ min: spec.min,
6612
+ max: spec.max,
6613
+ step: spec.step,
6614
+ def: spec.def,
6615
+ fmt: spec.fmt,
6616
+ onChange: spec.onChange
6617
+ }, field)]
6618
+ });
6619
+ }
6620
+ function RuleCard({ p, rule, index, total, active, liveIndex, notify, sel, onSel, field, onField, onRemove }) {
6455
6621
  const { t } = p;
6456
6622
  const [dragOver, setDragOver] = (0, react.useState)(false);
6457
6623
  const pendingDrag = (0, react.useRef)(null);
@@ -7049,42 +7215,14 @@ window.__ModuleLoader__.load({
7049
7215
  })
7050
7216
  ]
7051
7217
  })] }),
7052
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7218
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7053
7219
  style: { marginTop: 16 },
7054
- children: [
7055
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
7056
- label: t("ruleOpacity"),
7057
- min: 0,
7058
- max: 100,
7059
- step: 1,
7060
- def: Math.round(rule.wallpaperOpacity * 100),
7061
- fmt: (v) => `${v}%`,
7062
- onChange: (v) => p.setRule(rule.id, { wallpaperOpacity: v / 100 })
7063
- }),
7064
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
7065
- label: t("ruleBrightness"),
7066
- min: 0,
7067
- max: 200,
7068
- step: 1,
7069
- def: rule.brightness ?? 100,
7070
- fmt: (v) => `${v}%`,
7071
- onChange: (v) => p.setRule(rule.id, { brightness: v })
7072
- }),
7073
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
7074
- label: t("ruleBlur"),
7075
- min: 0,
7076
- max: 60,
7077
- step: 1,
7078
- def: rule.blur,
7079
- fmt: (v) => `${v}px`,
7080
- onChange: (v) => p.setRule(rule.id, { blur: v })
7081
- }),
7082
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7083
- className: "dab-hint",
7084
- style: { marginTop: 10 },
7085
- children: t("ruleBlurHint")
7086
- })
7087
- ]
7220
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WallpaperEffectRow, {
7221
+ p,
7222
+ rule,
7223
+ field,
7224
+ onField
7225
+ })
7088
7226
  }),
7089
7227
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7090
7228
  style: { marginTop: 14 },
@@ -7258,6 +7396,41 @@ window.__ModuleLoader__.load({
7258
7396
  });
7259
7397
  }
7260
7398
  //#endregion
7399
+ //#region src/format.ts
7400
+ /**
7401
+ * Byte counts as a person reads them — the only two sentences this plugin says
7402
+ * about size, in one place so their difference cannot be an accident.
7403
+ *
7404
+ * They are NOT the same question, which is why there are two:
7405
+ *
7406
+ * - a download's size describes ONE file that just arrived;
7407
+ * - a storage figure describes a total that is routinely zero.
7408
+ *
7409
+ * The floor below is right for the first and wrong for the second, and the wrong
7410
+ * answer is not a crash: it is a card that offers to clean up 1 KB and a button
7411
+ * that refuses to, on a store where there is nothing to clean. Neither number is
7412
+ * ever deleted or written — these are display strings — but a maintenance panel
7413
+ * whose numbers disagree with its own button is worse than no panel.
7414
+ */
7415
+ /**
7416
+ * Size of one download, at the precision a person reads it at.
7417
+ *
7418
+ * Floors at 1 KB on purpose: it is shown beside a file that just arrived, and
7419
+ * "0 KB" beside a picture that downloaded fine reads as a failure.
7420
+ */
7421
+ const fmtBytes = (n) => n >= 1048576 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
7422
+ /**
7423
+ * A figure on the storage card, where zero is a real and — on a tidy store — the
7424
+ * usual answer.
7425
+ *
7426
+ * `fmtBytes` would print "1 KB" for it, so the card claimed 1 KB of waste while
7427
+ * the clean button sat disabled (its condition is `orphanBytes === 0`, which was
7428
+ * true all along). Rounding remains conservative in the other direction: a real
7429
+ * orphan is never allowed to read as zero, because a number that cannot go down
7430
+ * is exactly the complaint this feature exists to answer.
7431
+ */
7432
+ const fmtStoreBytes = (n) => n > 0 ? fmtBytes(n) : "0 KB";
7433
+ //#endregion
7261
7434
  //#region src/client/components/pages/ProfilePage.tsx
7262
7435
  /** Chip labels, keyed by the value they set — so no chip can be built from a
7263
7436
  * value the schema does not accept, and `check:ui` still sees every key used. */
@@ -7301,8 +7474,6 @@ window.__ModuleLoader__.load({
7301
7474
  "too many images": "recFailTooMany",
7302
7475
  "too large": "recFailTooLarge"
7303
7476
  };
7304
- /** Size of a download, at the precision a person reads it at. */
7305
- const fmtBytes = (n) => n >= 1048576 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
7306
7477
  function ProfilePage({ p, notify }) {
7307
7478
  const { t, exportTheme, importTheme, setTransition } = p;
7308
7479
  const importRef = (0, react.useRef)(null);
@@ -7405,6 +7576,46 @@ window.__ModuleLoader__.load({
7405
7576
  setPending(null);
7406
7577
  notify(ok ? t("recDone") : t("recFailApply"), ok);
7407
7578
  };
7579
+ const [usage, setUsage] = (0, react.useState)(null);
7580
+ /** Distinguishes "not asked yet" from "asked, and the host cannot answer". */
7581
+ const [usageRead, setUsageRead] = (0, react.useState)(false);
7582
+ const [storeBusy, setStoreBusy] = (0, react.useState)(false);
7583
+ const refreshStore = async () => {
7584
+ const next = await p.readStoreUsage();
7585
+ setUsage(next);
7586
+ setUsageRead(true);
7587
+ };
7588
+ (0, react.useEffect)(() => {
7589
+ let live = true;
7590
+ (async () => {
7591
+ const next = await p.readStoreUsage();
7592
+ if (!live) return;
7593
+ setUsage(next);
7594
+ setUsageRead(true);
7595
+ })();
7596
+ return () => {
7597
+ live = false;
7598
+ };
7599
+ }, [store.rulesRev]);
7600
+ const onClean = async () => {
7601
+ setStoreBusy(true);
7602
+ const res = await p.pruneOrphanImages();
7603
+ await refreshStore();
7604
+ setStoreBusy(false);
7605
+ if (res === null) {
7606
+ notify(t("storeUnknown"), false);
7607
+ return;
7608
+ }
7609
+ if (res.refused) {
7610
+ notify(t("storeRefused"), false);
7611
+ return;
7612
+ }
7613
+ if (res.failed.length > 0) {
7614
+ notify(`${t("storePartial")}${res.failed.join(", ")}`, false);
7615
+ return;
7616
+ }
7617
+ notify(res.removed.length === 0 ? t("storeCleanNone") : `${t("storeDoneFreed")} ${fmtStoreBytes(res.freedBytes)}`, true);
7618
+ };
7408
7619
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
7409
7620
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
7410
7621
  className: "dab-head dab-rise",
@@ -7687,6 +7898,74 @@ window.__ModuleLoader__.load({
7687
7898
  }
7688
7899
  })
7689
7900
  ]
7901
+ }),
7902
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
7903
+ className: "dab-card dab-card-hover",
7904
+ children: [
7905
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7906
+ className: "dab-profile-ico",
7907
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 17 })
7908
+ }),
7909
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7910
+ className: "dab-profile-title",
7911
+ children: t("storeCardTitle")
7912
+ }),
7913
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7914
+ className: "dab-profile-desc",
7915
+ children: t("storeCardDesc")
7916
+ }),
7917
+ usage === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7918
+ className: "dab-hint",
7919
+ style: { marginBottom: 12 },
7920
+ children: usageRead ? t("storeUnknown") : t("storeReading")
7921
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7922
+ className: "dab-store-stats",
7923
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7924
+ className: "dab-store-stat",
7925
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7926
+ className: "dab-store-stat-v",
7927
+ children: fmtStoreBytes(usage.orphanBytes)
7928
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7929
+ className: "dab-store-stat-k",
7930
+ children: t("storeOrphans")
7931
+ })]
7932
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7933
+ className: "dab-store-stat",
7934
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7935
+ className: "dab-store-stat-v",
7936
+ children: usage.orphans.length
7937
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7938
+ className: "dab-store-stat-k",
7939
+ children: t("storeFiles")
7940
+ })]
7941
+ })]
7942
+ }), usage.trusted ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7943
+ className: "dab-hint dab-stale",
7944
+ style: { marginBottom: 12 },
7945
+ children: t("storeRefused")
7946
+ })] }),
7947
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7948
+ style: {
7949
+ display: "flex",
7950
+ gap: 8,
7951
+ flexWrap: "wrap"
7952
+ },
7953
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7954
+ type: "button",
7955
+ className: "dab-btn",
7956
+ disabled: storeBusy || usage === null || usage.orphanBytes === 0 || !usage.trusted,
7957
+ title: usage !== null && !usage.trusted ? t("storeRefused") : usage !== null && usage.orphanBytes === 0 ? t("storeCleanNone") : t("storeClean"),
7958
+ onClick: () => void onClean(),
7959
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 14 }), t("storeClean")]
7960
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7961
+ type: "button",
7962
+ className: "dab-btn dab-btn-ghost",
7963
+ disabled: storeBusy,
7964
+ onClick: () => void refreshStore(),
7965
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshIcon, { size: 14 }), t("storeRefresh")]
7966
+ })]
7967
+ })
7968
+ ]
7690
7969
  })
7691
7970
  ]
7692
7971
  }),
@@ -8590,6 +8869,12 @@ window.__ModuleLoader__.load({
8590
8869
  applyRecommended: async (preset) => {
8591
8870
  if (!canWriteConfig()) return false;
8592
8871
  return await applyThemeFile({ applyActive }, preset, true);
8872
+ },
8873
+ readStoreUsage: () => readStoreUsage(cfg.rules.flatMap(ruleSlots)),
8874
+ pruneOrphanImages: async () => {
8875
+ const res = await pruneOrphanImages(cfg.rules.flatMap(ruleSlots));
8876
+ for (const slot of res?.removed ?? []) setImage(slot, null);
8877
+ return res;
8593
8878
  }
8594
8879
  };
8595
8880
  };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
3
3
  //#region src/holiday.ts
4
4
  const HOLIDAYS = [{
@@ -464,6 +464,41 @@ function presetRetryDelayMs(failedAttempt) {
464
464
  return 400 * 2 ** Math.max(0, failedAttempt - 1);
465
465
  }
466
466
  //#endregion
467
+ //#region src/store.ts
468
+ /**
469
+ * Every slot the config still points at, in first-seen order and without repeats.
470
+ *
471
+ * Rules only. A holiday's slot is deliberately NOT included: its art lives in
472
+ * `holiday-cache/`, it is never written to the slot namespace, and a
473
+ * `modelbg-h-*` file therefore cannot belong to anything (see the read-only
474
+ * invariant in MAINTAINING §7.1). Counting the slot as referenced would make such
475
+ * a file permanently unprunable — the one residue this cleanup could never clear.
476
+ */
477
+ function referencedSlots(rules) {
478
+ const seen = /* @__PURE__ */ new Set();
479
+ const out = [];
480
+ for (const rule of rules) for (const image of rule.images) {
481
+ if (typeof image.slot !== "string" || seen.has(image.slot)) continue;
482
+ seen.add(image.slot);
483
+ out.push(image.slot);
484
+ }
485
+ return out;
486
+ }
487
+ /**
488
+ * The files whose slot appears in neither `referenced` nor `kept`.
489
+ *
490
+ * `stored` order is preserved because the caller deletes in it and reports what it
491
+ * removed in that order, which is the only order the user has ever seen these files
492
+ * in. `kept` exists for one reason: the browser half's live config can be a few
493
+ * hundred milliseconds ahead of its own write to disk (a slot is written, then the
494
+ * config that names it), and an image whose bytes have landed but whose rule entry
495
+ * has not must not be deleted in that window.
496
+ */
497
+ function orphanSlots(stored, referenced, kept = []) {
498
+ const live = /* @__PURE__ */ new Set([...referenced, ...kept]);
499
+ return stored.filter((slot) => !live.has(slot));
500
+ }
501
+ //#endregion
467
502
  //#region src/index.ts
468
503
  /**
469
504
  * Node half of dsh-background-by-model: file-backed rule persistence.
@@ -674,6 +709,61 @@ async function readConfig() {
674
709
  }
675
710
  return normalizeConfig(await migrateLegacy(raw));
676
711
  }
712
+ /**
713
+ * The config, plus whether it is safe to decide what the store still owns from it.
714
+ *
715
+ * `readConfig` deliberately swallows a corrupt file and answers with defaults —
716
+ * right for painting (a broken file must not leave a blank panel) and WRONG for
717
+ * the cleanup, which would then see zero referenced slots and treat every image on
718
+ * disk as an orphan. That is the one way this feature could delete a user's whole
719
+ * wallpaper collection, so the two cases are told apart here and the sweep refuses
720
+ * rather than guesses:
721
+ *
722
+ * - no file at all → trusted. A fresh store has images only if something wrote
723
+ * them, and nothing did;
724
+ * - a file that parses to an object → trusted (its rules are what they say);
725
+ * - a file that is there but does not parse, or is not an object → NOT trusted.
726
+ *
727
+ * A config whose `rules` are missing entirely is trusted: `normalizeConfig` seeds
728
+ * one blank rule for that case, which owns no slot, and the sanitizer's answer is
729
+ * exactly what a reload would paint.
730
+ *
731
+ * The untrusted branch deliberately does NOT go through `migrateLegacy`: that one
732
+ * WRITES a repaired config back to disk for the shapes earlier releases left
733
+ * behind, and running it here would turn "refuse to delete" into "quietly rewrite
734
+ * whatever the user's broken file said, then delete against the rewrite". The
735
+ * display answer for an unreadable file is the sanitizer's defaults, computed
736
+ * without touching the disk.
737
+ */
738
+ async function readConfigForSweep() {
739
+ await ensureDir();
740
+ let text;
741
+ try {
742
+ text = await readFile(configPath(), "utf8");
743
+ } catch {
744
+ return {
745
+ config: await readConfig(),
746
+ trusted: true
747
+ };
748
+ }
749
+ let raw;
750
+ try {
751
+ raw = JSON.parse(text);
752
+ } catch {
753
+ return {
754
+ config: normalizeConfig({}),
755
+ trusted: false
756
+ };
757
+ }
758
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
759
+ config: normalizeConfig({}),
760
+ trusted: false
761
+ };
762
+ return {
763
+ config: normalizeConfig(await migrateLegacy(raw)),
764
+ trusted: true
765
+ };
766
+ }
677
767
  const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
678
768
  "color",
679
769
  "bgMode",
@@ -1159,6 +1249,106 @@ async function resetStore() {
1159
1249
  return ok;
1160
1250
  }
1161
1251
  /**
1252
+ * What the image store occupies, and which of it nothing points at any more.
1253
+ *
1254
+ * Read-only: it reports, it never deletes. Splitting the two is what lets the panel
1255
+ * show a number before asking, and it is also the only honest way to answer "how
1256
+ * much would this free" — a cleanup whose size is announced AFTER the fact is a
1257
+ * cleanup nobody agrees to.
1258
+ *
1259
+ * `keep` is the browser half's own slot list. It is unioned with the config ON DISK
1260
+ * rather than replacing it because the two are not written at the same moment: an
1261
+ * upload writes the bytes first and the config that names them a moment later, so a
1262
+ * read landing in between would see a file the store is about to legitimately own.
1263
+ * The union can only ever make the report SMALLER (a slot is spared), which is the
1264
+ * safe direction for something whose answer is "delete these files".
1265
+ *
1266
+ * A file that cannot be stat'd is still listed, at 0 bytes: it exists as far as
1267
+ * `readdir` is concerned, and hiding it would leave a residue the panel claims is
1268
+ * not there.
1269
+ *
1270
+ * `trusted` is false when the config file on disk is there but unreadable (see
1271
+ * `readConfigForSweep`). The numbers are then still reported — the user asked what
1272
+ * the store occupies, and that question has an answer — but the caller must not
1273
+ * delete: with no trustworthy rule list, EVERY file would look like an orphan.
1274
+ */
1275
+ async function storeUsage(keep) {
1276
+ const stored = await listSlots();
1277
+ const { config, trusted } = await readConfigForSweep();
1278
+ const orphans = orphanSlots(stored, referencedSlots(config.rules), keep);
1279
+ const sizes = /* @__PURE__ */ new Map();
1280
+ let totalBytes = 0;
1281
+ for (const slot of stored) {
1282
+ let bytes = 0;
1283
+ try {
1284
+ bytes = (await stat(imagePath(slot))).size;
1285
+ } catch {}
1286
+ sizes.set(slot, bytes);
1287
+ totalBytes += bytes;
1288
+ }
1289
+ let orphanBytes = 0;
1290
+ for (const slot of orphans) orphanBytes += sizes.get(slot) ?? 0;
1291
+ return {
1292
+ totalBytes,
1293
+ usedBytes: totalBytes - orphanBytes,
1294
+ orphanBytes,
1295
+ files: stored.length,
1296
+ slots: stored,
1297
+ orphans: orphans.map((slot) => ({
1298
+ slot,
1299
+ bytes: sizes.get(slot) ?? 0
1300
+ })),
1301
+ trusted
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Delete every stored image nothing points at, and report what was removed.
1306
+ *
1307
+ * `keep` is the caller's live slot list, with the same meaning and the same
1308
+ * union-with-disk rule as `storeUsage`. The answer carries the freed byte count so
1309
+ * the panel can say what happened, and `failed` names the slots that resisted —
1310
+ * a cleanup that removed six of seven files and said "done" would leave the user
1311
+ * with a number that never goes down and no way to learn why.
1312
+ *
1313
+ * Failures are per file and never abort the sweep: one locked file must not protect
1314
+ * the other six. Nothing else in the store is touched — the config, the festival art
1315
+ * and the released generations under `holiday-cache/` are all outside this walk.
1316
+ *
1317
+ * REFUSES on an unreadable config (`trusted: false`): every file would look
1318
+ * unreferenced, and this is the one operation in the plugin that could delete a
1319
+ * whole wallpaper collection in a single press. A refusal costs a number on a card;
1320
+ * guessing costs the user their pictures.
1321
+ */
1322
+ async function pruneOrphans(keep) {
1323
+ const usage = await storeUsage(keep);
1324
+ if (!usage.trusted) {
1325
+ console.warn("dsh-background-by-model: refusing to prune — \"theme-config.json\" is unreadable, so nothing on disk can be told apart from an orphan");
1326
+ return {
1327
+ removed: [],
1328
+ failed: [],
1329
+ freedBytes: 0,
1330
+ refused: true
1331
+ };
1332
+ }
1333
+ const removed = [];
1334
+ const failed = [];
1335
+ let freedBytes = 0;
1336
+ for (const entry of usage.orphans) try {
1337
+ await rm(imagePath(entry.slot), { force: true });
1338
+ removed.push(entry.slot);
1339
+ freedBytes += entry.bytes;
1340
+ } catch (e) {
1341
+ failed.push(entry.slot);
1342
+ console.warn(`dsh-background-by-model: failed to remove the orphaned image for slot "${entry.slot}"`, e);
1343
+ }
1344
+ return {
1345
+ removed,
1346
+ failed,
1347
+ freedBytes,
1348
+ refused: false
1349
+ };
1350
+ }
1351
+ /**
1162
1352
  * Download an image from a network URL into one slot (replacing its bytes).
1163
1353
  * Returns { ok, dataUrl?, error? }; never throws.
1164
1354
  */
@@ -1277,6 +1467,8 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1277
1467
  const method = endpoint.startsWith(`${RPC_NS}/`) ? endpoint.slice(21) : "";
1278
1468
  const rawSlot = payload?.slot;
1279
1469
  const slot = typeof rawSlot === "string" && SLOT_RE.test(rawSlot) ? rawSlot : null;
1470
+ const rawKeep = payload?.keep;
1471
+ const keep = Array.isArray(rawKeep) ? rawKeep.filter((s) => typeof s === "string" && SLOT_RE.test(s)) : [];
1280
1472
  try {
1281
1473
  switch (method) {
1282
1474
  case "read": return {
@@ -1350,6 +1542,14 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1350
1542
  ok: true,
1351
1543
  value: await resetStore()
1352
1544
  };
1545
+ case "storeUsage": return {
1546
+ ok: true,
1547
+ value: await storeUsage(keep)
1548
+ };
1549
+ case "pruneOrphans": return {
1550
+ ok: true,
1551
+ value: await pruneOrphans(keep)
1552
+ };
1353
1553
  case "defaultModel": return {
1354
1554
  ok: true,
1355
1555
  value: defaultModel(ctx)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-background-by-model",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Appearance plugin for DeepSeek Harness: an ordered list of model rules, each carrying its own wallpapers — every image with its own framing and theme color — plus layout mode, opacity, brightness and blur; the background follows the model you switch to, and a rule can rotate through several images on its own rhythm.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -65,8 +65,9 @@
65
65
  "check:ui": "node scripts/ui-strings-check.ts",
66
66
  "check:watchdogs": "node scripts/watchdogs-check.ts",
67
67
  "check:scheme": "node scripts/scheme-check.ts",
68
+ "check:store": "node scripts/store-check.ts",
68
69
  "check:node": "node scripts/node-half-check.mjs",
69
- "test": "tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/code-plate-check.ts && node scripts/image-mode-check.ts && node scripts/rules-view-check.ts && node scripts/preset-check.ts && node scripts/ui-strings-check.ts && node scripts/watchdogs-check.ts && node scripts/scheme-check.ts && node scripts/node-half-check.mjs"
70
+ "test": "tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/code-plate-check.ts && node scripts/image-mode-check.ts && node scripts/rules-view-check.ts && node scripts/preset-check.ts && node scripts/ui-strings-check.ts && node scripts/watchdogs-check.ts && node scripts/scheme-check.ts && node scripts/store-check.ts && node scripts/node-half-check.mjs"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@deepseek-ai/dsh-home-paths": ">=0.2.1-alpha.2",