dsh-background-by-model 0.10.0 → 0.11.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 CHANGED
@@ -1,6 +1,19 @@
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.10.0 / v0.9.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.11.0 / v0.10.0).
4
+
5
+ ## v0.11.0
6
+
7
+ - **The runtime download source moves to Bilibili's image CDN** — the recommended profile's seven wallpapers and both holiday pictures now come from `i0`/`i1`/`i2.hdslb.com`, three edges falling back to each other (measured: same path, same bytes, identical sha1). The README's three screenshots play no part at runtime and stay where they were.
8
+ - **The recommended profile's rules ship inside the plugin** — seven model rules and their interface numbers moved into `src/profile.ts`, and the button downloads only the seven pictures. It no longer depends on GitHub being reachable, and the `preset.json` manifest, the schema-version gate and the "not a profile" error codes retire with it: what gets installed is always what this build itself writes, and nothing remote chooses what that is.
9
+ - **The request set is a compile-time constant** — slot → BFS path, written out in one table, and `fetchPresetImage` honours only that table: a slot it does not carry answers `bad slot` before any request, so a wrong press cannot become "fetch 64 files because somebody else's document said so". `PRESET_IMAGE_COUNT_MAX` and that whole attack surface go with it.
10
+ - **A BFS path is a content hash, so an address is immutable** — the "a tag can be re-pointed at different bytes" case cannot arise, which also retires "remember to bump `*_ASSETS_REF` along with the art".
11
+ - **The addresses must carry `@3840w.webp`** — the uploads are 2.4–12 MB PNGs (76 MB for the set); that suffix is what serves 250–365 KB 4K WebPs, byte-for-byte what an existing data directory already holds, so nothing is re-downloaded and nothing loses detail on a 4K screen.
12
+ - **The festival cache is keyed by content hash** — `holiday-cache/<tag>/` became `holiday-cache/<first eight of the hash>/`, computed by `holidayArtGeneration` from the URL. New art means a new address, therefore a new directory and a guaranteed re-download, with no version constant to remember; the old tag directories (and 0.7.9's flat layout) are taken by the same `pruneHolidayCache` once the new art lands, whose keep-set is the deduplicated set of "the current generation of each holiday".
13
+ - **`SCHEMA_VERSION` stays 10** — nothing entered or left the config shape: what changed is where these numbers come from, not what they look like, so an older host and an older exported file both still read it.
14
+ - **`check:preset` now pins the built-in profile itself** — rule order and paintability, the slot ↔ URL table agreeing in both directions, the webp transform and content hash in every address, `profileImagePath`'s null whitelist, generations that cannot collide, and the old remote wiring being gone.
15
+ - **`check:node` rewrites the holiday and profile sections** — the three edges asked in order and always for the SAME path, a holiday failing only when every edge does, a retry budget of nine requests (three edges × three rounds) for weather worth waiting out, the old `fetchPresetConfig` method provably gone, the slot whitelist (holiday slots, table-missing slots and malformed names all refused before anything is requested), a stale `holiday-cache/v0.1.0` directory being swept, and `resetStore` still leaving the festival art alone.
16
+ - **`check:scheme` section 3 no longer reaches the network** — it used to fetch the remote `preset.json` and compare schemas; that manifest is gone, so it now pins that the client asks for no head at all, that the slot list comes from the built-in table, and that the addresses carry the webp transform at Bilibili's hosts.
4
17
 
5
18
  ## v0.10.0
6
19
 
package/CHANGELOG.zh.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # 更新日志
2
2
 
3
- > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.10.0 / v0.9.2)。
3
+ > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.11.0 / v0.10.0)。
4
+
5
+ ## v0.11.0
6
+
7
+ - **运行时下载源换成 B 站图床** —— 推荐配置的七张壁纸与中秋、国庆两张节日画全部改从 `i0/i1/i2.hdslb.com` 取,三个边缘互为回落(实测同样路径、同样字节,sha1 一致)。README 的三张截图不参与运行时,仍留在原来的地方。
8
+ - **推荐配置的规则改成随插件内置** —— 七条模型规则与那套界面数值搬进 `src/profile.ts`,按钮只下载七张图。于是它不再依赖 GitHub 能通,`preset.json` 清单、schema 版本闸与「不是配置」这类错误码一起退休:能被安装的规则永远就是这份构建自己写的那些,远端没有任何东西在选择装什么。
9
+ - **请求清单变成编译期常量** —— 槽位 → BFS 路径写死在一张表里,而 `fetchPresetImage` 只认这张表:表里没有的槽位直接回 `bad slot`,所以一次按错不可能变成「按别人给的清单去取 64 个文件」。`PRESET_IMAGE_COUNT_MAX` 与 65 张图那条攻击面随之消失。
10
+ - **BFS 路径是内容哈希,地址天然不可变** —— 不再有「tag 被重新指向别的字节」这种可能;顺带把「换素材要记得同时改 `*_ASSETS_REF`」这个坑一起消掉了。
11
+ - **地址必须带 `@3840w.webp`** —— 上传的原图是 2.4–12 MB 的 PNG(九张合计 76 MB),这样取才是 250–365 KB 的 4K WebP,并且与现有数据目录里那几份的 sha1 逐字节一致,所以 4K 屏上不掉画质、老用户也不需要重下。
12
+ - **节日画的缓存目录按内容哈希分代** —— `holiday-cache/<tag>/` 变成 `holiday-cache/<哈希前八位>/`,由 `holidayArtGeneration` 从 URL 现算。换图 = 换地址 = 换目录 = 必然重下,没有需要记得去改的版本常量;旧的 tag 目录(以及 0.7.9 的平铺布局)在新图落盘后由同一次 `pruneHolidayCache` 带走,清扫时的保留集是「每个节日当前那一代」的去重集合。
13
+ - **`SCHEMA_VERSION` 保持 10** —— 这次没有字段进出配置形状:变的是这些数值从哪来,不是它们长什么样,所以旧宿主、旧导出文件都照常读。
14
+ - **`check:preset` 改成钉内置配置本身** —— 规则顺序与可画性、槽位 ↔ 地址表的双向一致、每个地址的 webp 变换与内容哈希、`profileImagePath` 的 null 白名单、缓存分代不重复,以及旧的远端接线已经不在。
15
+ - **`check:node` 重写节日与推荐配置两节** —— 三个边缘的依次回落与「同一条路径」、全部边缘失败才失败、「一次坏天气值得再来一轮」的重试预算(3 边缘 × 3 轮 = 9 个请求封顶)、旧 `fetchPresetConfig` 方法确实已不存在、槽位白名单(节假日槽位、表外槽位、非法名都在下载前被拒)、`holiday-cache/v0.1.0` 这类旧目录会被清扫,以及 `resetStore` 仍然不动节日画。
16
+ - **`check:scheme` 第 3 节不再联网** —— 它原本要拉远端 `preset.json` 比 schema,现在清单不存在了,改为钉住「客户端不再请求清单」「槽位清单来自内置表」「地址带 webp 变换且指向 B 站」。
4
17
 
5
18
  ## v0.10.0
6
19
 
package/README.md CHANGED
@@ -107,7 +107,7 @@ One switch on the Config page and nothing else: on 中秋节 and 国庆节 the w
107
107
 
108
108
  - **中秋 is 农历八月十五**, resolved from the Chinese calendar in **Asia/Shanghai**, so it moves every year (2024-09-17, 2025-10-06, 2026-09-25 …); **国庆 is October 1–7**; when the two collide, 中秋 wins.
109
109
  - **A holiday with no image falls through** to the model rules rather than blanking the wallpaper.
110
- - **The art is downloaded, not shipped** — one compressed wallpaper per holiday, pinned to a release tag of the project's [assets repository](https://github.com/HarmlessFunny/assets) and cached under `holiday-cache/<tag>/` on the first read of the day. The download is gated on the calendar, so an ordinary day fetches nothing at all and every later boot is answered from disk.
110
+ - **The art is downloaded, not shipped** — one compressed wallpaper per holiday, served by **Bilibili's image CDN** (`i0`/`i1`/`i2.hdslb.com`, three edges falling back to each other) and cached under `holiday-cache/<hash>/` on the first read of the day. The directory is named after the **content hash** in the URL: new art means a new address, therefore a new directory and a guaranteed re-download — there is no version constant to remember to bump. The download is gated on the calendar, so an ordinary day fetches nothing at all and every later boot is answered from disk.
111
111
  - **Neither the art nor its palette is swappable** — a holiday slot is read-only, the colors and the full-bleed Fill mode come from the definition, and theme exports carry your rules only.
112
112
 
113
113
  ### Per-rule and per-image settings
@@ -126,12 +126,15 @@ One switch on the Config page and nothing else: on 中秋节 and 国庆节 the w
126
126
 
127
127
  ### Recommended profile
128
128
 
129
- One click on the Config tab downloads the author's own configuration — rules, wallpapers and interface settings — from the project's assets repository.
129
+ One click on the Config tab installs the author's own configuration — seven model rules and their interface numbers **ship inside the plugin**, and only the seven wallpapers are downloaded, from **Bilibili's image CDN**.
130
130
 
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`.
131
+ - **The rules are no longer a remote file** — they are compiled into the bundle, at the same version as the code that reads them. So this button does not need GitHub to be reachable, and there is no "the remote profile is newer than this build" case left to refuse: what gets installed is always what this build itself writes.
132
+ - **The wallpaper addresses are a constant table** — slot → BFS path, fixed at build time. The request set never comes off the network, so a wrong press cannot turn into "fetch 64 files because somebody else's document said so".
133
+ - **One address, one set of bytes** — a BFS path is a hash of the upload, so a wallpaper URL is immutable in a way a git tag is not.
134
+ - **`@3840w.webp` is load-bearing** — the uploads are 2.4–12 MB PNGs (76 MB for the set); with that suffix they are the 250–365 KB 4K WebPs an existing data directory already holds, byte for byte.
132
135
  - **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
136
  - **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
- - **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.
137
+ - **One missing wallpaper fails the whole attempt and names the slot**, and every file retries on its own (three edges × three rounds), keeping what arrived for the next press.
135
138
 
136
139
  ### Image storage cleanup
137
140
 
@@ -166,7 +169,7 @@ Data directory: `~/.dsh/.dsh-background-by-model-data/` (Windows: `C:\Users\<you
166
169
  | `theme-config.json` | The rule list (each rule with its image list, and each image with its own look and rotation settings) plus the global Interface settings, the global switch effect and the holiday overrides |
167
170
  | `theme-config.json.bak` | The previous config, as of the last **successful** save. Every save writes a temp file and renames it over the live one, rotating the current good file into `.bak` first; a live file that will not parse is restored from here instead of falling back to the factory defaults. With neither readable, the cleanup card refuses to delete images |
168
171
  | `modelbg-<slot>` | One image, stored as raw bytes without a file extension — one file per image, so a rule with three images owns three slots |
169
- | `holiday-cache/<tag>/<asset>` | Festival art downloaded from the CDN, one file per holiday (`mid-autumn.webp`, `national-day.webp`) inside a directory named after the assets release this build pins (`v0.1.0`). The tag is part of the path so that a build pinning a new tag starts from an empty directory instead of being answered with the previous generation's bytes; the file inside is named after the **asset**, deliberately outside the slot namespace, which is what keeps a holiday slot read-only. Superseded generations are pruned once the new art is on disk. Delete a file here and it is simply re-downloaded the next time that holiday is in force |
172
+ | `holiday-cache/<hash>/<asset>` | Festival art downloaded from Bilibili's image CDN, one file per holiday (`mid-autumn.webp`, `national-day.webp`) inside a directory named after the **first eight characters of that picture's content hash** (`42e22801`, `30e97211` today). The hash is part of the path so that new art lands in a directory nobody has written to — it cannot be answered with the previous generation's bytes, and there is no version constant to keep in step. The file inside is named after the **asset**, deliberately outside the slot namespace, which is what keeps a holiday slot read-only. Superseded generations (including every `holiday-cache/<tag>/` from 0.10 and earlier) are pruned once the new art is on disk. Delete a file here and it is simply re-downloaded the next time that holiday is in force |
170
173
 
171
174
  ## Installation
172
175
 
@@ -228,12 +231,12 @@ After changing anything under `src/`, run `pnpm run bundle` again — the mounte
228
231
  - **`check:code-plate`** — the code block's plate: its tokens declared on both `:root` and `body`, the light branch emitting the banner, and each surface reading its own plugin variable.
229
232
  - **`check:image-mode`** — the lift onto the image for the layout mode and the three paint effects, and which image a control edits against which the render layer reads.
230
233
  - **`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.
231
- - **`check:preset`** — the recommended profile's retry policy and what a failed attempt holds for the next one, with both halves shown using them.
234
+ - **`check:preset`** — the profile itself (its rules, its slot → URL table and its generation constant) alongside the retry policy and what a failed attempt holds for the next one, with both halves shown using them. The rules are compiled in now, so this is also what catches a rule pointing at a slot the table does not carry — a mistake that would otherwise only show up after a release.
232
235
  - **`check:ui`** — both dictionaries carrying the same keys, every `dab-…` class styled, no dead entry, and every `var(--dsw-…)` published by the installed theme.
233
236
  - **`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.
234
- - **`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.
237
+ - **`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. It no longer reaches the network: the recommended profile's manifest it used to compare against is compiled in now.
235
238
  - **`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.
236
- - **`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 AND effect lifts, the global switch effect, the atomic config write with its backup and its recovery, the store's usage report and cleanup, and the recommended profile end to end.
239
+ - **`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 content-hash cache and its pruning, fallback across the three edges, the per-image color AND effect lifts, the global switch effect, the atomic config write with its backup and its recovery, the store's usage report and cleanup, and the recommended profile's seven pictures end to end (including the old manifest method being gone, the slot whitelist, and the retry budget).
237
240
 
238
241
  ## FAQ
239
242
 
@@ -289,6 +292,16 @@ The effect is **Instant**, or the duration is `0` — an effect and a duration a
289
292
 
290
293
  > Only the two most recent releases are listed here; older ones live in [CHANGELOG.md](./CHANGELOG.md).
291
294
 
295
+ ### v0.11.0
296
+
297
+ - **The download source moves to Bilibili's image CDN** — the recommended profile's seven wallpapers and both holiday pictures now come from `i0`/`i1`/`i2.hdslb.com`, three edges falling back to each other; the three screenshots in this README stay where they are, since they play no part at runtime.
298
+ - **The recommended profile's rules ship inside the plugin** — seven model rules and their interface numbers are compiled into the bundle, and the button downloads only the seven pictures. It no longer needs GitHub to be reachable, and there is no "the remote profile is newer than this build" gate left, nor a `preset.json` manifest — what gets installed is always what this build itself writes.
299
+ - **The request set is a constant table** — slot → BFS path, fixed at build time, and a BFS path is a hash of the upload. So a wallpaper URL is immutable in a way a git tag is not.
300
+ - **The addresses carry `@3840w.webp`** — the uploads are 2.4–12 MB PNGs (76 MB for the set); with that suffix they are the 250–365 KB 4K WebPs whose sha1 matches what is already in a data directory.
301
+ - **The festival cache is keyed by content hash** — `holiday-cache/<tag>/` became `holiday-cache/<first eight of the hash>/`. New art means a new address, therefore a new directory and a guaranteed re-download; the "remember to bump the version too" trap is gone, and the old tag directories are taken by the same sweep once the new art lands.
302
+ - **`SCHEMA_VERSION` stays 10** — nothing entered or left the config shape: what changed is where those numbers come from, not what they look like.
303
+ - **Checks** — `check:preset` now pins the built-in profile itself (rule order, the slot ↔ URL table, the webp transform and content hash in each address, the cache generation); `check:node` rewrites the holiday and profile sections (three-edge fallback and the retry budget, the old `fetchPresetConfig` method being gone, the slot whitelist, tag directories being swept); section 3 of `check:scheme` no longer reaches the network.
304
+
292
305
  ### v0.10.0
293
306
 
294
307
  - **The config is written atomically, with a `.bak` of the last good file** — a save writes a temp file and renames it over the live one, keeping the previous parseable file as the backup; a corrupt live file is restored from it instead of falling back to the factory defaults and then being overwritten by your next drag.
@@ -298,11 +311,6 @@ The effect is **Instant**, or the duration is `0` — an effect and a duration a
298
311
  - **A switch between two different opacity, brightness or blur values animates now** — each layer keeps its own picture's filter, and the wallpaper's opacity rides the transition instead of snapping.
299
312
  - **Dragging one of the three sliders no longer repaints everything per frame** — a drag frame writes only that field's property, and one full repaint lands when the gesture ends.
300
313
 
301
- ### v0.9.2
302
-
303
- - **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.
304
- - **`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.
305
-
306
314
  ## Star History
307
315
  [![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)
308
316
 
package/README.zh.md CHANGED
@@ -107,7 +107,7 @@
107
107
 
108
108
  - **中秋是农历八月十五**,按**北京时间**从中国农历解析,所以每年都动(2024-09-17、2025-10-06、2026-09-25 …);**国庆是 10 月 1–7 日**;两者撞上时中秋优先。
109
109
  - **没有图的节日会顺延**给模型规则,而不是把背景刷白。
110
- - **画是下载的,不随包发布** —— 每个节日一张压缩后的壁纸,钉在项目 [assets 仓库](https://github.com/HarmlessFunny/assets)的一个发行 tag 上,当天第一次读取时缓存到 `holiday-cache/<tag>/`。下载由日历把关,所以平常的日子一个字节都不取,此后的每一次启动都直接读盘。
110
+ - **画是下载的,不随包发布** —— 每个节日一张压缩后的壁纸,从**B 站图床**(`i0/i1/i2.hdslb.com`,三个边缘互为回落)取,当天第一次读取时缓存到 `holiday-cache/<哈希>/`。缓存目录名来自 URL 里那段**内容哈希**:换图就必须换地址,于是换目录、必然重下,没有需要记得去改的版本号。下载由日历把关,所以平常的日子一个字节都不取,此后的每一次启动都直接读盘。
111
111
  - **画和它的配色都不可以换** —— 节日槽位只读,颜色与满幅的「填充」都取自定义,主题导出也只带你的规则。
112
112
 
113
113
  ### 每条规则与每张图各自的设置
@@ -126,12 +126,15 @@
126
126
 
127
127
  ### 推荐配置
128
128
 
129
- 「配置」页点一下就下载作者本人的整套配置 —— 规则、壁纸与界面设置 —— 来自项目的 assets 仓库。
129
+ 「配置」页点一下就换上作者本人的整套配置 —— 七条模型规则与那套界面数值**随插件内置**,只有七张壁纸从**B 站图床**下载。
130
130
 
131
- - **托管在数据目录自己的形状里**(`preset/preset.json`、`preset/theme-config.json`,每张壁纸一个 `modelbg-<slot>` 文件),所以它能在 GitHub 上直接编辑,发布它就是一次 `cp`。
131
+ - **规则不再是远端文件** —— 它编译在包里,和读它的代码同一个版本。所以这个按钮不需要 GitHub 能通,也不存在「远端配置比插件新」这种要拦的情况:能被安装的规则,永远是这份构建自己写的那些。
132
+ - **壁纸地址是一张常量表** —— 槽位 → BFS 路径,编译期就定死了。请求清单因此不是从网上下载来的,一次按错也不可能变成「按别人的清单去取 64 个文件」。
133
+ - **一个地址对应一份内容** —— BFS 的路径是上传内容的哈希,所以壁纸地址天然不可变,不像 git tag 那样能被重新指向别的字节。
134
+ - **`@3840w.webp` 是必须的** —— 它们上传的原图是 2.4–12 MB 的 PNG(九张合计 76 MB),加上这个后缀才是 250–365 KB 的 4K WebP,也正是现有数据目录里已经存着的那份字节(sha1 一致)。
132
135
  - **里面每一个界面数字都是插件本来就用着的值**,所以在新装上是「加上规则和图片」;而在你已经调过的数据目录上,它把这些也一起重置 —— 它是一整份配置,不是一个补丁。
133
136
  - **先下载、后提问**,所以确认框能如实说出手里有什么 —— 而清空在下载**之后**,绝不在之前。它是面板里唯一一张会**先问再删**的卡片。
134
- - **少一张壁纸就整体失败并报出是哪个槽位**,而且每个文件各自重试,已经下到的部分留给下一次。
137
+ - **少一张壁纸就整体失败并报出是哪个槽位**,而且每个文件各自重试(三个边缘 × 三轮),已经下到的部分留给下一次。
135
138
 
136
139
  ### 图片存储清理
137
140
 
@@ -166,7 +169,7 @@
166
169
  | `theme-config.json` | 规则列表(每条规则含图片列表,每张图含自己的外观与轮换设置)+ 全局「界面」设置 + 全局切换效果 + 节日覆盖配置 |
167
170
  | `theme-config.json.bak` | 上一份**写成功时**的配置。每次保存都是「先写临时文件、再改名覆盖」,并在覆盖前把当前这份合格的文件留成 `.bak`;主文件读不出来时就从这里恢复,而不是退回出厂默认。读不出且没有备份时,清理卡会拒绝删图 |
168
171
  | `modelbg-<slot>` | 一张图片,保存为原始字节、无扩展名 —— 一张图一个文件,所以一条有三张图的规则占三个槽位 |
169
- | `holiday-cache/<tag>/<asset>` | 从 CDN 下载的节日画,一个节日一个文件(`mid-autumn.webp`、`national-day.webp`),装在一个以**插件所钉的 assets 发行版**命名的目录里(`v0.1.0`)。tag 进了路径,所以钉了新 tag 的构建从一个空目录开始,而不是被告知上一代的字节就是答案;目录里的文件名仍然是**素材名**,刻意放在槽位命名空间之外,这正是节日槽位只读的原因。被取代的旧代会在新图落盘之后被清掉。删掉这里的文件也没关系,下次那个节日生效时会重新下载 |
172
+ | `holiday-cache/<哈希>/<asset>` | 从 B 站图床下载的节日画,一个节日一个文件(`mid-autumn.webp`、`national-day.webp`),装在一个以**该图内容哈希的前八位**命名的目录里(今天是 `42e22801`、`30e97211`)。哈希进了路径,所以换了图就是换了一个空目录,不可能被告知上一代的字节就是答案 —— 也没有需要记得去改的版本常量;目录里的文件名仍然是**素材名**,刻意放在槽位命名空间之外,这正是节日槽位只读的原因。被取代的旧代(包括 0.10 及更早的 `holiday-cache/<tag>/`)会在新图落盘之后被清掉。删掉这里的文件也没关系,下次那个节日生效时会重新下载 |
170
173
 
171
174
  ## 安装
172
175
 
@@ -228,12 +231,12 @@ pnpm test # 构建 lib/、类型检查、跑十三道检查
228
231
  - **`check:code-plate`** —— 代码块底板:token 在 `:root` 与 `body` 上都有声明、浅色分支发出 banner、两块表面各读自己的插件变量。
229
232
  - **`check:image-mode`** —— 布局模式与三个壁纸效果放到图片上的那次抬升,以及某个控件编辑的是哪张图、渲染层读的又是哪张图。
230
233
  - **`check:rules`** —— 选项卡背后那两个住在 `src/client/rules-view.ts` 的判定,以及布局所依赖的页面结构。
231
- - **`check:preset`** —— 推荐配置的重试策略与失败那一次给下一次留下什么,并验证两侧真的在用它们。
234
+ - **`check:preset`** —— 推荐配置本身(规则、槽位 ↔ 地址表、那一代常量)与重试策略,以及失败那一次给下一次留下什么,并验证两侧真的在用它们。规则现在编译在包里,所以这里也负责拦住「某条规则指向一个表里没有的槽位」这种只有发布后才会发现的错。
232
235
  - **`check:ui`** —— 两本字典键集一致、每个 `dab-…` 类都有样式、没有死键,以及每个 `var(--dsw-…)` 都是已安装主题发布过的 token。
233
236
  - **`check:watchdogs`** —— 一条心跳、按各自到期时间触发、隐藏即暂停、dispose 不留定时器,且入口之外没有模块自己起 interval。
234
237
  - **`check:scheme`** —— `data-ds-dark-theme` 的交接:插件只改自己写的那个值、清掉 token 时一起交还标记、方案变了会重绘。
235
238
  - **`check:store`** —— 存储清理的两个判定:`referencedSlots` 与 `orphanSlots`。钉住「正在用的图不会被当成孤儿」与「节日槽位的文件必须可清」,外加浏览器半边传的是**规则槽位**而不是整份配置。
236
- - **`check:node`** —— 把构建好的 node 半边通过真实的 RPC 处理器跑一遍(`DSH_HOME` 指向临时目录、`fetch` 换成桩):节日配置块、节日画的缓存与清理、镜像回落、逐图颜色与逐图效果的抬升、全局切换效果、配置的原子写与备份/恢复、图片存储的用量报告与清理,以及推荐配置的整条链路。
239
+ - **`check:node`** —— 把构建好的 node 半边通过真实的 RPC 处理器跑一遍(`DSH_HOME` 指向临时目录、`fetch` 换成桩):节日配置块、节日画按内容哈希的缓存与清理、三个边缘的回落、逐图颜色与逐图效果的抬升、全局切换效果、配置的原子写与备份/恢复、图片存储的用量报告与清理,以及推荐配置的七张图(含旧清单方法的消失、槽位白名单、超时与重试预算)。
237
240
 
238
241
  ## 常见问题
239
242
 
@@ -289,6 +292,16 @@ pnpm test # 构建 lib/、类型检查、跑十三道检查
289
292
 
290
293
  > 这里只列最近两个版本;更早的版本见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
291
294
 
295
+ ### v0.11.0
296
+
297
+ - **下载源换成 B 站图床** —— 推荐配置的七张壁纸与中秋、国庆两张节日画,全部改从 `i0/i1/i2.hdslb.com` 取,三个边缘互为回落;README 里那三张截图仍留在原处,它们不参与运行时。
298
+ - **推荐配置的规则改成随插件内置** —— 七条模型规则与那套界面数值编译进包里,按钮只下载七张图。于是它不再需要 GitHub 能通,也不再有「远端配置比插件新」这道闸和 `preset.json` 清单 —— 能被安装的规则永远就是这份构建自己写的那些。
299
+ - **请求清单变成一张常量表** —— 槽位 → BFS 路径编译期定死,而 BFS 路径是上传内容的哈希,所以壁纸地址天然不可变,不像 git tag 那样能被重新指向别的字节。
300
+ - **地址带 `@3840w.webp`** —— 上传的原图是 2.4–12 MB 的 PNG(九张合计 76 MB),加上这个后缀才是 250–365 KB 的 4K WebP,与现有数据目录里那几份字节的 sha1 完全一致。
301
+ - **节日画的缓存目录按内容哈希分代** —— 从 `holiday-cache/<tag>/` 改成 `holiday-cache/<哈希前八位>/`。换图就换地址、换目录、必然重下,那个「记得顺手把版本号也改了」的坑从此不存在;旧的 tag 目录会在新图落盘后由同一次清扫带走。
302
+ - **`SCHEMA_VERSION` 仍是 10** —— 这次没有字段进出配置形状:变的是这些数值从哪来,不是它们长什么样。
303
+ - **门禁** —— `check:preset` 改钉内置配置本身(规则顺序、槽位 ↔ 地址表、地址里的 webp 变换与内容哈希、缓存分代),`check:node` 重写节日与推荐配置两节(三个边缘的回落与重试预算、旧 `fetchPresetConfig` 方法已不存在、槽位白名单、tag 目录被清扫),`check:scheme` 第 3 节不再联网。
304
+
292
305
  ### v0.10.0
293
306
 
294
307
  - **配置改成原子写,并留一份 `.bak`** —— 保存先写临时文件再改名覆盖,覆盖前把当前能解析的那份留成备份;主文件坏了就从备份恢复,而不是退回出厂默认、再被你下一次拖动覆盖掉。
@@ -298,11 +311,6 @@ pnpm test # 构建 lib/、类型检查、跑十三道检查
298
311
  - **透明度、亮度或模糊不同的两张图之间切换现在有动画了** —— 每个图层记住自己那张图的滤镜,壁纸透明度也跟着过渡走,不再直接切。
299
312
  - **拖动这三个滑块不再每帧整块重绘** —— 拖动只写那个字段自己的属性,松手才走一次完整重绘。
300
313
 
301
- ### v0.9.2
302
-
303
- - **「配置」页多了一张「图片存储」卡片** —— 先报出可清理多少、涉及几个文件,再给你一个清理按钮;只删没有规则引用的文件,配置与节日画都不动,删不掉的文件会点名。
304
- - **`check:store`,于是现在十三道校验** —— 两份 README 此前一直写着「十道」,`check:watchdogs` 与 `check:scheme` 从 v0.8.1 起就没列进去,这次一并补上。
305
-
306
314
  ## Star History
307
315
 
308
316
  [![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
@@ -103,12 +103,14 @@ window.__ModuleLoader__.load({
103
103
  id: "mid-autumn",
104
104
  slot: "h-midautumn",
105
105
  asset: "mid-autumn.webp",
106
+ source: "/bfs/new_dyn/42e22801827aff71267b03c95fcfd7834168597.png@1672w.webp",
106
107
  color: "#384A77",
107
108
  kind: "lunar"
108
109
  }, {
109
110
  id: "national-day",
110
111
  slot: "h-nationalday",
111
112
  asset: "national-day.webp",
113
+ source: "/bfs/new_dyn/30e97211189f7869dae83b909d2a3cc84168597.png@3456w.webp",
112
114
  color: "#FFF6EB",
113
115
  kind: "gregorian"
114
116
  }];
@@ -1167,17 +1169,13 @@ window.__ModuleLoader__.load({
1167
1169
  };
1168
1170
  }
1169
1171
  /**
1170
- * The profile's manifest and config, in one answer. Reads nothing, writes nothing.
1172
+ * One of the profile's wallpapers, addressed by the slot the built-in config names.
1171
1173
  *
1172
1174
  * Raw rather than `rpcCall` for the same reason `fetchImageUrl` is: the node half
1173
- * answers this one with a value that CARRIES its own failure, and the `error`
1174
- * inside it is the only thing worth showing the user — the generic "rpc failed"
1175
- * log would throw that distinction away.
1175
+ * answers this one with a value that CARRIES its own failure, and the `error` inside
1176
+ * it is the only thing worth showing the user — the generic "rpc failed" log would
1177
+ * throw that distinction away.
1176
1178
  */
1177
- async function fetchPresetConfig() {
1178
- return unwrapPreset(await rpcRaw("fetchPresetConfig", {}));
1179
- }
1180
- /** One of the profile's wallpapers, addressed by the slot the config names. */
1181
1179
  async function fetchPresetImage(slot) {
1182
1180
  return unwrapPreset(await rpcRaw("fetchPresetImage", { slot }));
1183
1181
  }
@@ -4970,7 +4968,7 @@ window.__ModuleLoader__.load({
4970
4968
  importDone: "已导入配置",
4971
4969
  importFail: "导入失败,文件格式不正确",
4972
4970
  recCardTitle: "推荐配置",
4973
- recCardDesc: "从项目素材库下载作者维护的一整套配置(规则、壁纸与接口设置),一键替换当前的全部内容",
4971
+ recCardDesc: "换上作者维护的一整套配置(规则与界面数值随插件内置,壁纸从图床下载),一键替换当前的全部内容",
4974
4972
  recAdopt: "采用推荐配置",
4975
4973
  recBusy: "正在下载",
4976
4974
  recConfirmTitle: "替换当前的全部配置?",
@@ -4980,12 +4978,9 @@ window.__ModuleLoader__.load({
4980
4978
  recConfirmOk: "删除并替换",
4981
4979
  recConfirmCancel: "取消",
4982
4980
  recDone: "已采用推荐配置",
4983
- recFailNetwork: "推荐配置下载失败,请检查网络后重试",
4984
- recFailNotProfile: "素材库返回的内容不是配置,请稍后重试",
4985
- recFailNewer: "这份推荐配置需要更新的插件版本,未做任何改动",
4986
- recFailTooMany: "这份推荐配置列出的图片数量超出限制,未做任何改动",
4981
+ recFailNetwork: "壁纸下载失败,请检查网络后重试(已下到的会留到下一次)",
4987
4982
  recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
4988
- recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
4983
+ recFailImage: "这张壁纸没拿到(图床上这个地址没有回应):",
4989
4984
  recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
4990
4985
  storeCardTitle: "图片存储",
4991
4986
  storeCardDesc: "因意外导致的孤儿图片这里可以清掉",
@@ -5154,7 +5149,7 @@ window.__ModuleLoader__.load({
5154
5149
  importDone: "Profile imported",
5155
5150
  importFail: "Import failed — invalid file",
5156
5151
  recCardTitle: "Recommended profile",
5157
- recCardDesc: "Download the author's own full setup — rules, wallpapers and interface settings — and replace everything with it",
5152
+ recCardDesc: "Install the author's own full setup — rules and interface numbers ship with the plugin, wallpapers are downloaded from the image host — replacing everything you have",
5158
5153
  recAdopt: "Use recommended profile",
5159
5154
  recBusy: "Downloading",
5160
5155
  recConfirmTitle: "Replace your entire profile?",
@@ -5164,12 +5159,9 @@ window.__ModuleLoader__.load({
5164
5159
  recConfirmOk: "Delete and replace",
5165
5160
  recConfirmCancel: "Cancel",
5166
5161
  recDone: "Recommended profile applied",
5167
- recFailNetwork: "Could not download the recommended profile — check your connection and retry",
5168
- recFailNotProfile: "The asset host answered with something that is not a profile — try again later",
5169
- recFailNewer: "This profile needs a newer plugin version. Nothing was changed.",
5170
- recFailTooMany: "The recommended profile names more images than the plugin will fetch. Nothing was changed.",
5162
+ recFailNetwork: "Could not download the wallpapers — check your connection and retry (what arrived is kept for the next try)",
5171
5163
  recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
5172
- recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
5164
+ recFailImage: "Could not fetch this wallpaper — the image host does not answer for that address:",
5173
5165
  recFailApply: "Could not write the recommended profile — your own is untouched",
5174
5166
  storeCardTitle: "Image storage",
5175
5167
  storeCardDesc: "Orphaned image files left behind by an accident can be cleared here",
@@ -5206,41 +5198,6 @@ window.__ModuleLoader__.load({
5206
5198
  //#endregion
5207
5199
  //#region src/preset.ts
5208
5200
  /**
5209
- * JSON with object keys in a fixed (sorted) order — the same string for the same
5210
- * value, whatever order the object that carried it happened to build its keys in.
5211
- *
5212
- * `JSON.stringify` alone is not an identity for a value that came off the wire and
5213
- * through a sanitizer: key order is an accident of how the object was assembled,
5214
- * and a resume that missed on it would silently re-download everything (the safe
5215
- * direction) while a resume that MATCHED on a value it should not have would be
5216
- * the unsafe one — so this is deliberately total as well: anything JSON cannot
5217
- * carry (a function, a `Symbol`, `undefined`) serializes as `null` and an
5218
- * `undefined` property is skipped, exactly as `JSON.stringify` treats it.
5219
- */
5220
- function canonical(value) {
5221
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
5222
- if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
5223
- return JSON.stringify(value) ?? "null";
5224
- }
5225
- /**
5226
- * The identity of one revision of the profile: the manifest's shape version plus
5227
- * the config, in canonical form.
5228
- *
5229
- * The profile is served at ONE version of the assets repository, but a version
5230
- * reference is only as fixed as the tag behind it: re-point the tag and every
5231
- * edge that has not been purged keeps answering with the previous bytes of the
5232
- * same URL. So "the profile" is still not guaranteed to be one fixed thing, and
5233
- * the bytes a previous attempt is holding are only known to belong to what is
5234
- * being installed when that config comes back the same. Order-insensitivity is
5235
- * what lets this be a comparison of VALUES rather than of spellings —
5236
- * `normalizeConfig` (./schema) rebuilds the config it reads, so pinning the key
5237
- * to the exact key order the wire happened to use would make the hold useless
5238
- * the moment anything upstream normalized differently.
5239
- */
5240
- function presetKey(version, config) {
5241
- return `${version}\n${canonical(config)}`;
5242
- }
5243
- /**
5244
5201
  * The bytes of a failed attempt that may be reused for the revision `key` names.
5245
5202
  *
5246
5203
  * A fresh object every time, never the held record itself: the caller fills it in
@@ -5264,6 +5221,145 @@ window.__ModuleLoader__.load({
5264
5221
  function presetMissing(slots, images) {
5265
5222
  return slots.filter((slot) => images[slot] === void 0);
5266
5223
  }
5224
+ /** Every slot the profile installs, in list order — the download set. */
5225
+ const PROFILE_SLOTS = [
5226
+ {
5227
+ slot: "m1",
5228
+ path: "/bfs/new_dyn/e4565384e7e5b2d75756e577f505534b4168597.png"
5229
+ },
5230
+ {
5231
+ slot: "m2",
5232
+ path: "/bfs/new_dyn/aa6e8649701c91e5ac8f43ff28b97a924168597.png"
5233
+ },
5234
+ {
5235
+ slot: "m3",
5236
+ path: "/bfs/new_dyn/5da92b31df5ee2a20b17ec6be353cf5c4168597.png"
5237
+ },
5238
+ {
5239
+ slot: "m4",
5240
+ path: "/bfs/new_dyn/14bb1e53cb2c13ca80bd23e3c1d2a04e4168597.png"
5241
+ },
5242
+ {
5243
+ slot: "m5",
5244
+ path: "/bfs/new_dyn/f51fff40de2d6eaa5a73d177f7bbfa794168597.png"
5245
+ },
5246
+ {
5247
+ slot: "m6",
5248
+ path: "/bfs/new_dyn/cfa71d4e60a2f12aaeec3efe1aa680b34168597.png"
5249
+ },
5250
+ {
5251
+ slot: "m7",
5252
+ path: "/bfs/new_dyn/c09d34b79d9b447d7db98da68b97afc54168597.png"
5253
+ }
5254
+ ].map((i) => i.slot);
5255
+ /**
5256
+ * Identity of this generation of the built-in profile.
5257
+ *
5258
+ * Only one thing reads it: what a failed download KEEPS for the next press
5259
+ * (`PresetHeld.key` in ./preset). Editing the rules or the pictures below must bump
5260
+ * it, or a retry could staple a wallpaper from the old set to a config from the new
5261
+ * one — the same reason the remote profile was compared by value rather than
5262
+ * assumed. Nothing else in the plugin persists it.
5263
+ */
5264
+ const RECOMMENDED_REVISION = "builtin-1";
5265
+ /**
5266
+ * One rule of the profile: the match string, its picture and that picture's color.
5267
+ *
5268
+ * The color is a per-image value (schema 10), and the triple here is the one the
5269
+ * profile has always carried — an HSL accent per model, used to tint the interface
5270
+ * while its wallpaper is on screen.
5271
+ */
5272
+ function profileRule(id, match, slot, color) {
5273
+ return {
5274
+ id,
5275
+ images: [defaultImage(slot, color, "fill")],
5276
+ match,
5277
+ enabled: true,
5278
+ color: null,
5279
+ rotate: defaultRotation()
5280
+ };
5281
+ }
5282
+ /**
5283
+ * The profile, as a fresh config every call.
5284
+ *
5285
+ * ORDER IS THE FALLBACK: matching runs top→bottom and the first usable rule is what
5286
+ * an unmatched model gets, so `deepseek` leads — the model this plugin was written
5287
+ * for. Everything below the rules is the interface numbers a fresh install already
5288
+ * starts on (`DEFAULT_PART_OPACITIES` and friends), which is what makes installing
5289
+ * the profile "add the rules and the pictures" on a clean store.
5290
+ *
5291
+ * Deliberately NOT taken from `freshThemeConfig()`: that one seeds a single BLANK
5292
+ * rule, and this one must not inherit it.
5293
+ */
5294
+ function recommendedConfig() {
5295
+ return {
5296
+ rules: [
5297
+ profileRule("r1", "deepseek", "m3", [
5298
+ 191.88931210132625,
5299
+ .5046198735613555,
5300
+ .82
5301
+ ]),
5302
+ profileRule("r2", "gpt", "m1", [
5303
+ 263.5122459614382,
5304
+ .31076923076923096,
5305
+ .82
5306
+ ]),
5307
+ profileRule("r3", "claude", "m2", [
5308
+ 18.923814597762384,
5309
+ .5486699795381468,
5310
+ .6
5311
+ ]),
5312
+ profileRule("r4", "qwen", "m4", [
5313
+ 26.25977410947,
5314
+ .6228354978354975,
5315
+ .82
5316
+ ]),
5317
+ profileRule("r5", "glm", "m5", [
5318
+ 30.15559737458117,
5319
+ .6552284141830319,
5320
+ .7646280172718907
5321
+ ]),
5322
+ profileRule("r6", "kimi", "m6", [
5323
+ 229.39645097591443,
5324
+ .5752269829617896,
5325
+ .2
5326
+ ]),
5327
+ profileRule("r7", "grok", "m7", [
5328
+ 217.3791348600509,
5329
+ .7702645947081058,
5330
+ .2
5331
+ ])
5332
+ ],
5333
+ opacities: { ...DEFAULT_PART_OPACITIES },
5334
+ transition: defaultTransition(),
5335
+ settingsOpacity: 1,
5336
+ chatTextOpacity: DEFAULT_CHAT_TEXT_OPACITY,
5337
+ trajectoryOpacity: DEFAULT_TRAJECTORY_OPACITY,
5338
+ rightbarOpacity: DEFAULT_RIGHTBAR_OPACITY,
5339
+ pageOpacity: 0,
5340
+ autoExtract: true,
5341
+ holidays: {
5342
+ enabled: true,
5343
+ items: defaultHolidayRules()
5344
+ }
5345
+ };
5346
+ }
5347
+ /**
5348
+ * The profile as the document both imports consume — the same shape `exportTheme`
5349
+ * writes, so everything downstream (the wipe, the slot loop) is the hand-picked
5350
+ * file's path.
5351
+ *
5352
+ * `images` is filled by the caller as the downloads land, which is what makes a
5353
+ * partially downloaded profile a resumable one rather than an all-or-nothing press.
5354
+ */
5355
+ function recommendedThemeFile(images = {}) {
5356
+ return {
5357
+ version: 10,
5358
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5359
+ config: recommendedConfig(),
5360
+ images
5361
+ };
5362
+ }
5267
5363
  //#endregion
5268
5364
  //#region src/client/repaint.ts
5269
5365
  /**
@@ -7722,19 +7818,15 @@ window.__ModuleLoader__.load({
7722
7818
  /**
7723
7819
  * The ways the recommended-profile download can fail, mapped to their copy.
7724
7820
  *
7725
- * They are worth telling apart because they ask different things of the user and
7726
- * of whoever maintains the assets repository: nothing answering is a network to
7727
- * retry, a mirror that answered with something that is not a profile is not
7728
- * going to start working on a retry, a profile written for a newer plugin is a
7729
- * "update this build", and too many images is a profile nobody should install.
7730
- * A failure that names a SLOT is a fifth case and is rendered separately — that
7731
- * one is the author's file naming, and only the slot tells them which.
7821
+ * Two cases, because the profile is now only PICTURES: the rules and their numbers
7822
+ * are compiled in, so there is no manifest to be malformed, no shape to be too new
7823
+ * and no config to name too many slots. What is left is the network ("retry") and
7824
+ * the cap on the total ("nobody should install this"). A failure that names a SLOT
7825
+ * is rendered separately — that one is about one BFS address, and the slot is what
7826
+ * says which.
7732
7827
  */
7733
7828
  const REC_ERROR_KEYS = {
7734
7829
  "download failed": "recFailNetwork",
7735
- "not a profile": "recFailNotProfile",
7736
- "newer version": "recFailNewer",
7737
- "too many images": "recFailTooMany",
7738
7830
  "too large": "recFailTooLarge"
7739
7831
  };
7740
7832
  function ProfilePage({ p, notify }) {
@@ -8418,12 +8510,12 @@ window.__ModuleLoader__.load({
8418
8510
  /**
8419
8511
  * Ceiling on what one recommended profile may add up to, across every wallpaper.
8420
8512
  *
8421
- * The node half caps each file and the number of files, but it answers one
8422
- * independent call at a time and never sees a running total — the client is the
8423
- * only side that does, so the total is bounded here. The real profile is 2.2 MB
8424
- * of images across seven of them, so this is generous by design: it exists to
8425
- * stop a config that names sixty-four eight-megabyte slots from turning one
8426
- * button press into half a gigabyte, not to second-guess the author.
8513
+ * The node half caps ONE file, but it answers one independent call at a time and
8514
+ * never sees a running total — the client is the only side that does, so the total
8515
+ * is bounded here. The real profile is 2.2 MB of images across seven of them, so
8516
+ * this is generous by design: it exists so that a build which grew a wrong URL
8517
+ * (or an edge that served an upload instead of the transform) cannot turn one
8518
+ * button press into half a gigabyte.
8427
8519
  */
8428
8520
  const PRESET_TOTAL_MAX = 33554432;
8429
8521
  /**
@@ -8434,7 +8526,8 @@ window.__ModuleLoader__.load({
8434
8526
  * CDN edge was still cold). What is being paid is per-request latency, and the
8435
8527
  * host speaks HTTP/2 — the bytes are identical either way and the connection is
8436
8528
  * reused — so overlapping them buys wall-clock time and risks nothing but a
8437
- * little contention.
8529
+ * little contention. The same holds for the Bilibili edges the pictures come from
8530
+ * now (see `PROFILE_HOSTS` in ../profile).
8438
8531
  *
8439
8532
  * Four rather than all seven: a burst that wide competes for the same bandwidth
8440
8533
  * and makes the per-file timeout harder to reason about, and four already hides
@@ -9060,30 +9153,28 @@ window.__ModuleLoader__.load({
9060
9153
  }
9061
9154
  },
9062
9155
  /**
9063
- * Fetch the project's recommended profile. Downloads only — applying it is
9064
- * separate, because the page has to ask before anything is destroyed and the
9065
- * question should describe what is actually in hand.
9156
+ * Fetch the recommended profile's wallpapers. Downloads only — applying them
9157
+ * is separate, because the page has to ask before anything is destroyed and
9158
+ * the question should describe what is actually in hand.
9159
+ *
9160
+ * The RULES are not fetched at all: they are compiled into the bundle
9161
+ * (./profile), so this is a set of picture transfers and nothing else. That
9162
+ * is why there is no manifest round, no shape gate and no "not a profile"
9163
+ * failure left — nothing remote can decide what this installs.
9066
9164
  *
9067
- * The profile is a DIRECTORY, so this is a pool rather than one call: the
9068
- * config names the slots and each wallpaper is one request, up to
9069
- * `PRESET_CONCURRENCY` at a time. Each file keeps its own mirror fallback,
9070
- * retry rounds (the node half's business, ./preset), validation and slot name
9071
- * in a failure.
9165
+ * Each slot is one request, up to `PRESET_CONCURRENCY` at a time, and each
9166
+ * file keeps its own host fallback, retry rounds (the node half's business,
9167
+ * ./preset), validation and slot name in a failure.
9072
9168
  *
9073
9169
  * STRICT: the first wallpaper that cannot be had stops the queue. What had
9074
9170
  * already arrived is kept for the next press (`presetHeld` below) — the
9075
9171
  * difference between retrying one file and retrying the lot. Nothing is
9076
- * touched here, so "the author renamed a file" arrives as an error naming the
9077
- * file rather than as a profile quietly missing a picture.
9172
+ * touched here, so a wrong URL arrives as an error naming the slot rather
9173
+ * than as a profile quietly missing a picture.
9078
9174
  */
9079
9175
  fetchRecommended: async (onProgress) => {
9080
- const head = await fetchPresetConfig();
9081
- if (!head.ok) return {
9082
- ok: false,
9083
- error: head.error
9084
- };
9085
- const slots = head.slots;
9086
- const key = presetKey(head.version, head.config);
9176
+ const slots = PROFILE_SLOTS;
9177
+ const key = RECOMMENDED_REVISION;
9087
9178
  const images = presetResume(presetHeld, key);
9088
9179
  let bytes = 0;
9089
9180
  for (const url of Object.values(images)) bytes += url.length;
@@ -9131,12 +9222,7 @@ window.__ModuleLoader__.load({
9131
9222
  presetHeld = null;
9132
9223
  return {
9133
9224
  ok: true,
9134
- preset: {
9135
- version: head.version,
9136
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9137
- config: head.config,
9138
- images
9139
- }
9225
+ preset: recommendedThemeFile(images)
9140
9226
  };
9141
9227
  },
9142
9228
  /** Replace EVERYTHING with a downloaded profile: empty the store, then write it. */
package/lib/index.js CHANGED
@@ -5,15 +5,31 @@ const HOLIDAYS = [{
5
5
  id: "mid-autumn",
6
6
  slot: "h-midautumn",
7
7
  asset: "mid-autumn.webp",
8
+ source: "/bfs/new_dyn/42e22801827aff71267b03c95fcfd7834168597.png@1672w.webp",
8
9
  color: "#384A77",
9
10
  kind: "lunar"
10
11
  }, {
11
12
  id: "national-day",
12
13
  slot: "h-nationalday",
13
14
  asset: "national-day.webp",
15
+ source: "/bfs/new_dyn/30e97211189f7869dae83b909d2a3cc84168597.png@3456w.webp",
14
16
  color: "#FFF6EB",
15
17
  kind: "gregorian"
16
18
  }];
19
+ /**
20
+ * The generation a holiday's ART belongs to, for the on-disk cache.
21
+ *
22
+ * The BFS path is content-addressed (`bfs/new_dyn/<sha1 of the upload>`), so the
23
+ * first eight characters of that hash are a content identity: change the art, change
24
+ * the directory, and a cache keyed by this cannot answer a new upload with the
25
+ * previous bytes. That is the property the pinned assets release tag used to provide
26
+ * and the reason it is now derivable instead of remembered — there is no version
27
+ * constant to bump, and a forgotten bump is not possible.
28
+ */
29
+ function holidayArtGeneration(def) {
30
+ const hash = /new_dyn\/([0-9a-f]+)/.exec(def.source);
31
+ return hash === null ? "v1" : hash[1].slice(0, 8);
32
+ }
17
33
  //#endregion
18
34
  //#region src/schema.ts
19
35
  /**
@@ -475,20 +491,21 @@ function normalizeConfig(raw) {
475
491
  *
476
492
  * Three reasons are terminal, and each for its own:
477
493
  *
478
- * - `missing` — the host says the file is not there. The profile follows a
479
- * BRANCH (see `PRESET_ASSET_HOSTS` in ./index), so a renamed or deleted file
480
- * has to reach the user as a loud failure NAMING the slot — and it has to
481
- * reach them FAST: paying two more rounds for a file that is provably absent
482
- * only makes the sentence later, on a button the user is watching.
494
+ * - `missing` — the host says the file is not there. A BFS path is a hash of an
495
+ * upload (see `PROFILE_HOSTS` in ./profile), so a 404 is not a flake that might
496
+ * clear: the picture it names either exists or never will. It has to reach the
497
+ * user as a loud failure NAMING the slot — and it has to reach them FAST:
498
+ * paying two more rounds for a file that is provably absent only makes the
499
+ * sentence later, on a button the user is watching.
483
500
  * - `oversized` — the bytes are the problem, and the next round would serve the
484
501
  * same bytes.
485
- * - `timeout` — sixty seconds of silence is not weather. Both mirrors were
486
- * already asked inside the SAME round, so a retry round would re-ask two hosts
487
- * that each just spent a minute not answering. This is also what keeps the
488
- * worst case for one file at three minutes instead of six: a round in which BOTH
489
- * mirrors were silent is over immediately (two minutes, exactly what it cost
490
- * before there were rounds at all), and only a round that mixed a silent mirror
491
- * with a transient one can carry a minute into the next round.
502
+ * - `timeout` — sixty seconds of silence is not weather. Every host was already
503
+ * asked inside the SAME round, so a retry round would re-ask hosts that each
504
+ * just spent a minute not answering. This is also what keeps the worst case for
505
+ * one file at three minutes instead of nine: a round in which every edge was
506
+ * silent is over immediately (three minutes, exactly what it cost before there
507
+ * were rounds at all), and only a round that mixed a silent edge with a
508
+ * transient one can carry a round's worth of latency into the next one.
492
509
  *
493
510
  * Everything else is a hiccup: a thrown connection, a 5xx, a 429, a 408, or a 200
494
511
  * with no body.
@@ -500,7 +517,7 @@ function presetRetryable(reason) {
500
517
  * The delay before the round that follows `failedAttempt` (1-based), in ms.
501
518
  *
502
519
  * Doubling rather than fixed: the second retry is the one that has to survive a
503
- * mirror having a bad few hundred milliseconds, and it is still short enough that
520
+ * edge having a bad few hundred milliseconds, and it is still short enough that
504
521
  * a user staring at "downloading" does not read it as a hang.
505
522
  */
506
523
  function presetRetryDelayMs(failedAttempt) {
@@ -542,6 +559,88 @@ function orphanSlots(stored, referenced, kept = []) {
542
559
  return stored.filter((slot) => !live.has(slot));
543
560
  }
544
561
  //#endregion
562
+ //#region src/profile.ts
563
+ /**
564
+ * Where the pictures are hosted.
565
+ *
566
+ * Three hosts, tried in order, and they are not mirrors of each other by
567
+ * configuration — `i0`/`i1`/`i2.hdslb.com` are separate BFS edges that answer the
568
+ * same path with the same bytes (verified by sha1), which is the same arrangement
569
+ * the festival art already relies on.
570
+ */
571
+ const PROFILE_HOSTS = [
572
+ "https://i0.hdslb.com",
573
+ "https://i1.hdslb.com",
574
+ "https://i2.hdslb.com"
575
+ ];
576
+ /**
577
+ * The BFS transform that turns the uploaded PNG into the file this plugin stores.
578
+ *
579
+ * Load-bearing, and the reason it is spelled out once here:
580
+ *
581
+ * - the UPLOADED files are 2.4–12 MB PNGs (76 MB for the set). `PRESET_IMAGE_MAX`
582
+ * is 8 MB and the store keeps full fidelity, so serving them as uploaded would
583
+ * both refuse the largest ones and put a 76 MB wallpaper collection on disk;
584
+ * - `@3840w.webp` answers 250–365 KB at 3840×2160, which is what the previous
585
+ * asset host served and what an existing store already holds — the sha1 of the
586
+ * bytes is identical, so this is the same picture, not a re-encode;
587
+ * - the suffix is plain arithmetic rather than a CDN feature that may move: the
588
+ * path before it is `bfs/new_dyn/<sha1 of the upload>`, i.e. CONTENT-ADDRESSED,
589
+ * so a URL here can never be re-pointed at different bytes the way a git tag
590
+ * can.
591
+ */
592
+ const BFS_SUFFIX = "@3840w.webp";
593
+ /**
594
+ * Slot → BFS path, for the seven wallpapers the profile is made of.
595
+ *
596
+ * The slots are `m1`..`m7` rather than names, because a slot is a FILENAME
597
+ * (`modelbg-<slot>`) and `nextSlot` hands out exactly this namespace. The mapping
598
+ * to a model is the rule list below, never the slot name.
599
+ */
600
+ const IMAGE_PATHS = [
601
+ {
602
+ slot: "m1",
603
+ path: "/bfs/new_dyn/e4565384e7e5b2d75756e577f505534b4168597.png"
604
+ },
605
+ {
606
+ slot: "m2",
607
+ path: "/bfs/new_dyn/aa6e8649701c91e5ac8f43ff28b97a924168597.png"
608
+ },
609
+ {
610
+ slot: "m3",
611
+ path: "/bfs/new_dyn/5da92b31df5ee2a20b17ec6be353cf5c4168597.png"
612
+ },
613
+ {
614
+ slot: "m4",
615
+ path: "/bfs/new_dyn/14bb1e53cb2c13ca80bd23e3c1d2a04e4168597.png"
616
+ },
617
+ {
618
+ slot: "m5",
619
+ path: "/bfs/new_dyn/f51fff40de2d6eaa5a73d177f7bbfa794168597.png"
620
+ },
621
+ {
622
+ slot: "m6",
623
+ path: "/bfs/new_dyn/cfa71d4e60a2f12aaeec3efe1aa680b34168597.png"
624
+ },
625
+ {
626
+ slot: "m7",
627
+ path: "/bfs/new_dyn/c09d34b79d9b447d7db98da68b97afc54168597.png"
628
+ }
629
+ ];
630
+ IMAGE_PATHS.map((i) => i.slot);
631
+ /**
632
+ * The URL one slot's wallpaper is fetched from, on the first host — `${host}${url}`
633
+ * is how a caller builds the other two.
634
+ *
635
+ * Returns null for a slot this profile does not carry, and that null is the whole
636
+ * whitelist: `fetchPresetImage` (./index) answers `bad slot` for it, so a caller on
637
+ * the public RPC cannot name a file of its own.
638
+ */
639
+ function profileImagePath(slot) {
640
+ const entry = IMAGE_PATHS.find((i) => i.slot === slot);
641
+ return entry === void 0 ? null : `${entry.path}${BFS_SUFFIX}`;
642
+ }
643
+ //#endregion
545
644
  //#region src/index.ts
546
645
  /**
547
646
  * Node half of dsh-background-by-model: file-backed rule persistence.
@@ -593,90 +692,56 @@ const LEGACY_FILES = [
593
692
  const WALLPAPER_FETCH_MAX = 26214400;
594
693
  const WALLPAPER_FETCH_TIMEOUT = 2e4;
595
694
  /**
596
- * Where the festival art is hosted, and which revision of it this build wants.
695
+ * Where the festival art is hosted.
597
696
  *
598
- * The art is fetched from the `assets` repository the first time a holiday needs it
599
- * (see `readHolidayAsset`), never shipped in the package.
697
+ * Three edges of Bilibili's image CDN, tried in order. They are not mirrors by
698
+ * configuration — `i0`/`i1`/`i2.hdslb.com` answer the same path with the same bytes
699
+ * (checked by sha1) — which is the same arrangement the recommended profile uses
700
+ * (see `PROFILE_HOSTS` in ./profile).
600
701
  *
601
- * The reference is a **release tag**, never a branch: runtime art has to be
602
- * immutable, and a branch reference would let a reshuffle of the documentation
603
- * screenshots silently kill a shipped feature. One tag also means one version of
604
- * everything the plugin fetches (art, profile, README screenshots). A MOVED tag
605
- * does not reach jsDelivr's edge on its own, so new art means a new tag, never a
606
- * force-push of the old one.
702
+ * A path under `bfs/new_dyn/` is CONTENT-ADDRESSED (the tail is a hash of the
703
+ * upload), so nothing here needs a version constant: the art a build names cannot be
704
+ * swapped under it the way a re-pointed git tag could, and `holidayArtGeneration`
705
+ * (./holiday) derives the cache's directory from that same hash.
607
706
  */
608
- const HOLIDAY_ASSETS_REPO = "HarmlessFunny/assets";
609
- const HOLIDAY_ASSETS_REF = "v0.1.0";
610
- const HOLIDAY_ASSETS_DIR = "dsh-background-by-model/holiday";
611
- /**
612
- * Mirrors, tried in order: jsDelivr is reachable where `raw.githubusercontent.com`
613
- * is not, and raw is the fallback for a CDN that is blocked or down. Both serve the
614
- * same version, so a fallback cannot mix revisions.
615
- */
616
- const HOLIDAY_ASSET_HOSTS = [`https://cdn.jsdelivr.net/gh/${HOLIDAY_ASSETS_REPO}@${HOLIDAY_ASSETS_REF}/${HOLIDAY_ASSETS_DIR}`, `https://raw.githubusercontent.com/${HOLIDAY_ASSETS_REPO}/${HOLIDAY_ASSETS_REF}/${HOLIDAY_ASSETS_DIR}`];
707
+ const HOLIDAY_HOSTS = [
708
+ "https://i0.hdslb.com",
709
+ "https://i1.hdslb.com",
710
+ "https://i2.hdslb.com"
711
+ ];
617
712
  /**
618
713
  * Sub-directory of the store holding downloaded festival art: one directory per
619
- * release of the asset repository, one file per asset inside it.
714
+ * GENERATION of the art (see `holidayArtGeneration`), one file per asset inside it.
620
715
  *
621
716
  * NOT part of the slot namespace, and must never become part of it: files are named
622
- * after the ASSET and their parent after the pinned release, never after a slot, so
623
- * nothing here can be mistaken for a user's `modelbg-<slot>`. That is what lets a
717
+ * after the ASSET and their parent after the art's content hash, never after a slot,
718
+ * so nothing here can be mistaken for a user's `modelbg-<slot>`. That is what lets a
624
719
  * holiday slot stay read-only while the art itself is downloaded.
625
720
  *
626
- * The version segment is load-bearing: a cache keyed by asset name alone would
627
- * answer a NEW generation with the OLD bytes and say nothing about it. With the tag
628
- * in the path, a build that pins a new tag looks in a directory nobody has written
629
- * to yet, so the first holiday after an update is a miss. Superseded generations
630
- * are swept by `pruneHolidayCache`.
721
+ * The generation segment is load-bearing: a cache keyed by asset name alone would
722
+ * answer a NEW upload with the OLD bytes and say nothing about it. With the hash in
723
+ * the path, a build that names new art looks in a directory nobody has written to
724
+ * yet, so the first holiday after an update is a miss. Superseded generations are
725
+ * swept by `pruneHolidayCache`.
631
726
  */
632
727
  const HOLIDAY_CACHE_DIR = "holiday-cache";
633
- /** Cap and timeout for one asset download; these files are 228 KB and 500 KB. */
728
+ /** Cap and timeout for one asset download; these files are 207 KB and 446 KB. */
634
729
  const HOLIDAY_FETCH_MAX = 8388608;
635
730
  const HOLIDAY_FETCH_TIMEOUT = 1e4;
636
731
  /**
637
- * The recommended profile, served from the same assets repository as the festival
638
- * art — as a DIRECTORY in the store's own shape: a readable `theme-config.json`
639
- * plus one raw file per slot. Publishing it is a `cp` out of the data directory,
640
- * not an export step.
641
- *
642
- * Nothing lists the images: the request set is DERIVED from the config's own
643
- * `rules[].images[].slot`, so adding a wallpaper is a rule entry and a file.
644
- *
645
- * Referenced by the same release tag as the art (one tag = one version of
646
- * everything the plugin fetches). The re-pointed-tag case is why `presetKey`
647
- * exists: an edge that has not been purged can still answer with the previous
648
- * bytes of the same URL, and a resumed download must not glue two revisions
649
- * together (see `fetchPresetConfig`). A profile written for a config shape this
650
- * build cannot read is refused by the `preset.json` gate.
651
- */
652
- const PRESET_ASSETS_REPO = "HarmlessFunny/assets";
653
- const PRESET_ASSETS_REF = "v0.1.0";
654
- const PRESET_ASSET_DIR = "dsh-background-by-model/preset";
655
- /** The shape gate: `{"version": 9}` — see SCHEMA_VERSION in ./schema. */
656
- const PRESET_MANIFEST = "preset.json";
657
- const PRESET_CONFIG = "theme-config.json";
658
- /** Images are named after the slot they fill, exactly as they are on disk. */
659
- const PRESET_IMAGE_PREFIX = "modelbg-";
660
- /** Mirrors, tried in order — same reason as the festival art's. */
661
- const PRESET_ASSET_HOSTS = [`https://cdn.jsdelivr.net/gh/${PRESET_ASSETS_REPO}@${PRESET_ASSETS_REF}/${PRESET_ASSET_DIR}`, `https://raw.githubusercontent.com/${PRESET_ASSETS_REPO}/${PRESET_ASSETS_REF}/${PRESET_ASSET_DIR}`];
662
- /**
663
- * Caps for the profile, now per file rather than for one document: a profile is a
664
- * SET of transfers, and each has a different way of going wrong.
665
- *
666
- * The count is the one that matters — the request set is derived from the config,
667
- * so a hostile config could name a thousand slots and turn one button press into a
668
- * thousand downloads. `SLOT_RE` bounds each name, `PRESET_IMAGE_COUNT_MAX` bounds
669
- * how many there are, and the running TOTAL is bounded on the client, which is the
670
- * only side that sees more than one file.
671
- *
672
- * The timeout is set by measurement: a 350 KB wallpaper takes a second or two over
673
- * jsDelivr, and the fallback mirror is the slower one, so a tight budget would fail
674
- * exactly in the situation it exists for.
732
+ * Caps for the recommended profile's pictures.
733
+ *
734
+ * A profile is a SET of transfers, and what can go wrong is different per file: the
735
+ * node half bounds ONE download (`PRESET_IMAGE_MAX`) and the client bounds the
736
+ * running TOTAL, because it is the only side that sees more than one file.
737
+ *
738
+ * The count is a constant now rather than something a remote config declares
739
+ * (`PROFILE_SLOTS` in ./profile), so there is nothing left to bound: seven names,
740
+ * each already inside `SLOT_RE`. The timeout is set by measurement — a 250–365 KB
741
+ * wallpaper takes a second or two over the CDN, and the second edge is the slower
742
+ * one, so a tight budget would fail exactly in the situation it exists for.
675
743
  */
676
- const PRESET_MANIFEST_MAX = 65536;
677
- const PRESET_CONFIG_MAX = 1048576;
678
744
  const PRESET_IMAGE_MAX = 8388608;
679
- const PRESET_IMAGE_COUNT_MAX = 64;
680
745
  const PRESET_FETCH_TIMEOUT = 6e4;
681
746
  const dataDir = () => dshHomePath(DATA_DIR);
682
747
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
@@ -684,7 +749,13 @@ const configBackupPath = () => dshHomePath(DATA_DIR, CONFIG_BACKUP);
684
749
  const configTempPath = () => dshHomePath(DATA_DIR, `${CONFIG_FILE}${CONFIG_TEMP}`);
685
750
  const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
686
751
  const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
687
- const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF, asset);
752
+ /** The cache directory of one GENERATION of the festival art (see `holidayCachePath`). */
753
+ const holidayGenerationPath = (generation) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, generation);
754
+ /** The cached file of one asset: its own generation directory, named by the asset. */
755
+ const holidayCachePath = (asset) => {
756
+ const def = HOLIDAYS.find((h) => h.asset === asset);
757
+ return dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, def === void 0 ? "v1" : holidayArtGeneration(def), asset);
758
+ };
688
759
  const exists = async (p) => {
689
760
  try {
690
761
  await access(p);
@@ -1021,7 +1092,7 @@ async function writeConfig(config) {
1021
1092
  *
1022
1093
  * The null answer is what a DOWNLOAD is checked against (see `fetchHolidayAsset`):
1023
1094
  * neither a status code nor a `content-type` header survives contact with a
1024
- * misconfigured mirror, so the payload's own magic bytes are the only claim that
1095
+ * misconfigured origin, so the payload's own magic bytes are the only claim that
1025
1096
  * cannot lie.
1026
1097
  */
1027
1098
  function imageMimeOrNull(buf) {
@@ -1094,10 +1165,12 @@ async function readHolidayAsset(asset) {
1094
1165
  holidayInFlight.set(asset, task);
1095
1166
  return await task;
1096
1167
  }
1097
- /** Try every mirror in turn; the first usable answer is cached and returned. */
1168
+ /** Try every host in turn; the first usable answer is cached and returned. */
1098
1169
  async function downloadHolidayAsset(asset) {
1099
- for (const host of HOLIDAY_ASSET_HOSTS) {
1100
- const buf = await fetchHolidayAsset(`${host}/${asset}`);
1170
+ const def = HOLIDAYS.find((h) => h.asset === asset);
1171
+ if (def === void 0) return null;
1172
+ for (const host of HOLIDAY_HOSTS) {
1173
+ const buf = await fetchHolidayAsset(`${host}${def.source}`);
1101
1174
  if (buf === null) continue;
1102
1175
  await writeHolidayCache(asset, buf);
1103
1176
  return buf;
@@ -1158,18 +1231,18 @@ async function fetchAsset(url, max, timeout) {
1158
1231
  }
1159
1232
  }
1160
1233
  /**
1161
- * The status codes worth another round: 5xx is a mirror having a bad moment, 429 one
1234
+ * The status codes worth another round: 5xx is a host having a bad moment, 429 one
1162
1235
  * asking to be left alone, 408 one giving up on us. Every other non-2xx means the
1163
- * file is not there (jsDelivr answers 403 outside the repository, 404 for a missing
1164
- * file), and that answer must arrive fast and unchanged.
1236
+ * file is not there (404 for a missing path, 403 for a refused one), and that answer
1237
+ * must arrive fast and unchanged.
1165
1238
  */
1166
1239
  function fetchFailureForStatus(status) {
1167
1240
  return status >= 500 || status === 429 || status === 408 ? "server" : "missing";
1168
1241
  }
1169
1242
  /**
1170
- * One festival picture, or null when no mirror served an image.
1243
+ * One festival picture, or null when no host served an image.
1171
1244
  *
1172
- * One round, unlike the profile's (`fetchPresetFile`): this is fetched during boot,
1245
+ * One round, unlike the profile's (`fetchProfileFile`): this is fetched during boot,
1173
1246
  * before the first wallpaper is painted, and a retry round would delay that paint.
1174
1247
  * The art is cached once it arrives and every later read tries again.
1175
1248
  */
@@ -1178,19 +1251,20 @@ async function fetchHolidayAsset(url) {
1178
1251
  return !got.ok || imageMimeOrNull(got.buf) === null ? null : got.buf;
1179
1252
  }
1180
1253
  /**
1181
- * One file out of the profile directory, or null when no mirror served it.
1254
+ * One of the profile's pictures, or null when no host served it.
1182
1255
  *
1183
- * Every mirror is asked in every round, so a round is "all the hosts there are" and
1184
- * the rounds are the retry (a mirror that answers 502 once and correctly the next
1256
+ * Every host is asked in every round, so a round is "all the hosts there are" and
1257
+ * the rounds are the retry (a host that answers 502 once and correctly the next
1185
1258
  * time, on the sixth of seven files). A round whose failures were all terminal — the
1186
- * file is gone, or too big — ends the attempts outright, so a renamed file costs two
1187
- * requests and a sentence naming the slot (`presetRetryable`, ./preset).
1259
+ * path is gone, or too big — ends the attempts outright, so a wrong URL costs three
1260
+ * requests rather than nine and a sentence naming the slot (`presetRetryable`,
1261
+ * ./preset).
1188
1262
  */
1189
- async function fetchPresetFile(name, max) {
1263
+ async function fetchProfileFile(path, max) {
1190
1264
  for (let attempt = 1; attempt <= 3; attempt++) {
1191
1265
  let retryable = false;
1192
- for (const host of PRESET_ASSET_HOSTS) {
1193
- const got = await fetchAsset(`${host}/${name}`, max, PRESET_FETCH_TIMEOUT);
1266
+ for (const host of PROFILE_HOSTS) {
1267
+ const got = await fetchAsset(`${host}${path}`, max, PRESET_FETCH_TIMEOUT);
1194
1268
  if (got.ok) return got.buf;
1195
1269
  if (presetRetryable(got.reason)) retryable = true;
1196
1270
  }
@@ -1200,102 +1274,30 @@ async function fetchPresetFile(name, max) {
1200
1274
  return null;
1201
1275
  }
1202
1276
  /**
1203
- * The profile's shape gate and its configuration, in one answer.
1204
- *
1205
- * Two files in order: the manifest says what SHAPE the config is written for, and a
1206
- * config this build cannot read is refused here — before anything is downloaded in
1207
- * earnest, long before anything is deleted. The config then says what the profile IS.
1277
+ * One of the profile's wallpapers, as a data URL ready to be written into a slot.
1208
1278
  *
1209
- * The sanitizer runs on this side on purpose: the download list is DERIVED from
1210
- * `rules[].images[].slot`, so an unsanitized config would let a remote file choose
1211
- * what this process requests. After `normalizeConfig` every slot has passed `SLOT_RE`
1212
- * (`normalizeImage`). Holiday slots are then DROPPED rather than refused: they are
1213
- * named legally in `holidays.items`, have no file at all, and `writeImage` refuses
1214
- * them anyway, so a rule pointing at one is already inert on the client.
1279
+ * The same two checks the festival art gets, for the same reason: the bytes must
1280
+ * actually be an image (`imageMimeOrNull`, never the `content-type` header — a CDN
1281
+ * may label a transform it does not serve), and the slot must be one this build is
1282
+ * willing to write. The slot name is re-checked here because this is a public RPC:
1283
+ * `profileImagePath` is the whitelist, and a name it does not carry has no URL at
1284
+ * all, so a caller cannot ask this process for a file of its own choosing.
1215
1285
  *
1216
1286
  * Nothing is cached to disk, unlike the festival art: that cache exists because the
1217
1287
  * art is fetched on a schedule and must work offline afterwards, while this is a
1218
1288
  * button whose whole point is to fetch what is there NOW.
1219
1289
  */
1220
- async function fetchPresetConfig() {
1221
- const manifestRaw = await fetchPresetFile(PRESET_MANIFEST, PRESET_MANIFEST_MAX);
1222
- if (manifestRaw === null) return {
1223
- ok: false,
1224
- error: "download failed"
1225
- };
1226
- let manifest;
1227
- try {
1228
- manifest = JSON.parse(manifestRaw.toString("utf8"));
1229
- } catch {
1230
- return {
1231
- ok: false,
1232
- error: "not a profile"
1233
- };
1234
- }
1235
- const declared = manifest?.version;
1236
- if (typeof declared !== "number" || !isFinite(declared)) return {
1237
- ok: false,
1238
- error: "not a profile"
1239
- };
1240
- if (declared > 10) {
1241
- console.warn(`dsh-background-by-model: the recommended profile declares schema ${declared}, this build writes 10`);
1242
- return {
1243
- ok: false,
1244
- error: "newer version"
1245
- };
1246
- }
1247
- if (declared < 10) console.warn(`dsh-background-by-model: the recommended profile is written for schema ${declared}; importing it with defaults for the fields it predates`);
1248
- const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
1249
- if (configRaw === null) return {
1250
- ok: false,
1251
- error: "download failed"
1252
- };
1253
- let raw;
1254
- try {
1255
- raw = JSON.parse(configRaw.toString("utf8"));
1256
- } catch {
1257
- return {
1258
- ok: false,
1259
- error: "not a profile"
1260
- };
1261
- }
1262
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
1263
- ok: false,
1264
- error: "not a profile"
1265
- };
1266
- const config = normalizeConfig(raw);
1267
- const slots = /* @__PURE__ */ new Set();
1268
- for (const rule of config.rules) for (const image of rule.images) {
1269
- if (holidayAssetFor(image.slot) !== null) continue;
1270
- slots.add(image.slot);
1271
- }
1272
- if (slots.size > PRESET_IMAGE_COUNT_MAX) return {
1273
- ok: false,
1274
- error: "too many images"
1275
- };
1276
- return {
1277
- ok: true,
1278
- version: declared,
1279
- config,
1280
- slots: [...slots]
1281
- };
1282
- }
1283
- /**
1284
- * One of the profile's wallpapers, as a data URL ready to be written into a slot.
1285
- *
1286
- * The same two checks the festival art gets, for the same reason: the bytes must
1287
- * actually be an image (`imageMimeOrNull`, never the `content-type` header — the
1288
- * mirror serves extensionless files as `application/octet-stream`), and the slot
1289
- * must be one this process is willing to write. The slot name is re-checked here
1290
- * because this is a public RPC: the list this side hands out is not the only
1291
- * thing a caller could ask for.
1292
- */
1293
1290
  async function fetchPresetImage(slot) {
1294
1291
  if (!SLOT_RE.test(slot) || holidayAssetFor(slot) !== null) return {
1295
1292
  ok: false,
1296
1293
  error: "bad slot"
1297
1294
  };
1298
- const buf = await fetchPresetFile(`${PRESET_IMAGE_PREFIX}${slot}`, PRESET_IMAGE_MAX);
1295
+ const path = profileImagePath(slot);
1296
+ if (path === null) return {
1297
+ ok: false,
1298
+ error: "bad slot"
1299
+ };
1300
+ const buf = await fetchProfileFile(path, PRESET_IMAGE_MAX);
1299
1301
  if (buf === null) return {
1300
1302
  ok: false,
1301
1303
  error: "download failed"
@@ -1320,7 +1322,9 @@ async function fetchPresetImage(slot) {
1320
1322
  async function writeHolidayCache(asset, buf) {
1321
1323
  try {
1322
1324
  await ensureDir();
1323
- await mkdir(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF), { recursive: true });
1325
+ const def = HOLIDAYS.find((h) => h.asset === asset);
1326
+ if (def === void 0) return;
1327
+ await mkdir(holidayGenerationPath(holidayArtGeneration(def)), { recursive: true });
1324
1328
  const target = holidayCachePath(asset);
1325
1329
  const temp = `${target}.tmp`;
1326
1330
  await writeFile(temp, buf);
@@ -1330,21 +1334,28 @@ async function writeHolidayCache(asset, buf) {
1330
1334
  console.warn(`dsh-background-by-model: could not cache the "${asset}" holiday art`, e);
1331
1335
  }
1332
1336
  }
1337
+ /** The generation directories this build still reads — one per holiday, deduped. */
1338
+ function holidayGenerations() {
1339
+ return [...new Set(HOLIDAYS.map(holidayArtGeneration))];
1340
+ }
1333
1341
  /**
1334
- * Drop every generation of the cache except the one this build reads.
1342
+ * Drop every generation of the cache except the ones this build reads.
1335
1343
  *
1336
- * Called AFTER a successful download, never before it: a pinned release that turns
1337
- * out to be unreachable must not cost the user the art they already had.
1344
+ * Called AFTER a successful download, never before it: a build whose art turns out
1345
+ * to be unreachable must not cost the user the art they already had.
1338
1346
  *
1339
- * It walks the cache ROOT and compares GENERATION names, never asset names, so the
1340
- * generation in use cannot be touched. That also makes an upgrade clean up after
1341
- * itself (0.7.9's flat `holiday-cache/<asset>` files are removed here).
1347
+ * It walks the cache ROOT and compares GENERATION names, never asset names, so a
1348
+ * generation in use cannot be touched. That is also what makes an upgrade clean up
1349
+ * after itself twice over: the release-tag directories of every earlier version
1350
+ * (`holiday-cache/<tag>/`) and 0.7.9's flat `holiday-cache/<asset>` files are both
1351
+ * gone from here, because neither name is a content hash.
1342
1352
  */
1343
1353
  async function pruneHolidayCache() {
1344
1354
  try {
1345
1355
  const root = dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR);
1356
+ const keep = new Set(holidayGenerations());
1346
1357
  for (const entry of await readdir(root)) {
1347
- if (entry === HOLIDAY_ASSETS_REF) continue;
1358
+ if (keep.has(entry)) continue;
1348
1359
  await rm(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, entry), {
1349
1360
  recursive: true,
1350
1361
  force: true
@@ -1701,10 +1712,6 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1701
1712
  ok: true,
1702
1713
  value: await fetchImageUrl(slot, payload?.url ?? null)
1703
1714
  };
1704
- case "fetchPresetConfig": return {
1705
- ok: true,
1706
- value: await fetchPresetConfig()
1707
- };
1708
1715
  case "fetchPresetImage":
1709
1716
  if (slot === null) return {
1710
1717
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-background-by-model",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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",