dsh-background-by-model 0.7.8 → 0.7.10

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,24 @@
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.7.8 / v0.7.7).
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.7.10 / v0.7.9).
4
+
5
+ ## v0.7.10
6
+
7
+ - **The festival art's cache is keyed by the release the plugin pins, so an updated plugin is answered by a download instead of by the previous generation's bytes** — the cache lived at `holiday-cache/<asset>`, and `readHolidayAsset` answers from it whenever the file exists and is a valid image (`const cached = await readFileOrNull(holidayCachePath(asset))` then `if (cached !== null && imageMimeOrNull(cached) !== null) return cached`): no version comparison, no network, no log line. Pointing a build at a new release of the assets repository therefore changed the URL it *would* have fetched and changed nothing about what it returned, because the old bytes were still sitting at `mid-autumn.webp`. That is precisely the SILENT failure the pinned reference exists to prevent — art that stops updating on a day nobody is watching — one layer below the reference, where pinning cannot reach. `holidayCachePath` now carries the tag (`holiday-cache/v0.1.0/mid-autumn.webp`), so the release is part of the file's IDENTITY: a build pinning a new tag looks in a directory nobody has written to, the first holiday after an update is a cache MISS, and `ls holiday-cache/` answers "which generations are on this disk".
8
+
9
+ - **A superseded generation is dropped, after the new art is on disk and never before it.** A versioned key on its own would leave every generation lying there forever — about 730 KB each (228 KB + 500 KB), including the assets a later release stops shipping at all, which nothing else would ever remove. `pruneHolidayCache` runs at the END of a successful cache write, after the rename: a pinned release that turns out to be unreachable must not cost a user the art they already had, and a download that failed has not earned the right to delete anything. It walks the cache root and removes DIRECTORY entries whose name is not this build's tag — the entries it compares are generation names, not asset names, so the generation in use cannot be touched however the comparison goes — and it is best-effort, because an unprunable cache is a cache with one extra directory in it and failing a festival over that would be the tail wagging the dog. `resetStore` still leaves every generation alone: that cache is not the user's configuration, and pruning is not its job.
10
+
11
+ - **Gates** — `check:node` had the tag written into two URL assertions and the cache path written as a third literal, so the suite now defines one `REF` that both assertions and `GEN = join(CACHE, REF)` read. Three assertions are new: art missing from the pinned generation is re-downloaded rather than read stale, a planted `v0.0.9` directory is gone once the new art has landed, and the generation in use still holds both assets afterwards. The corrupt-cache-entry, dead-mirror-fallback and raced-cold-read sections were repointed at the generation directory and pass unchanged. `pnpm test` is ten checks, all passing, and `lib/` is that run's output.
12
+
13
+ ## v0.7.9
14
+
15
+ - **Every asset reference is pinned to one release tag of the assets repository (`v0.1.0`), and the three that had drifted apart now agree** — the README's three screenshots were read from the `main` branch, the festival art was pinned to a raw commit hash (`HOLIDAY_ASSETS_COMMIT = '58a9e107…'`), and the recommended profile followed `main` deliberately. Three references, three different answers to "which bytes is this build looking at", and only one of them was actually immune to the repository moving underneath it. `HOLIDAY_ASSETS_COMMIT` is now `HOLIDAY_ASSETS_REF = 'v0.1.0'` — renamed with the value, since it no longer names a commit — and `PRESET_ASSETS_REF` carries the same string, so one release of `HarmlessFunny/assets` is one version of every byte the plugin fetches: the festival art, the recommended profile and the README's images together, in a form a person can read, compare against the tags on GitHub and update in one place rather than a 40-character hash nobody can date.
16
+
17
+ - **The festival art's bytes do not move with the name.** `58a9e107` is an ancestor of `v0.1.0` and both `dsh-background-by-model/holiday/*.webp` files are **byte-identical** between the two revisions (the same blobs, `46ccfc89…` and `dc5080e4…`), so the download is the same download — the diff between the revisions is the profile arriving (seven `preset/modelbg-*` files, `preset.json` and `theme-config.json`, none of which existed at that commit) plus the three screenshots being re-shot. What did change is that the raw mirror carries the tag as well (`raw.githubusercontent.com/HarmlessFunny/assets/v0.1.0/…`), which is what keeps "both mirrors serve the same revision" true by VALUE rather than by convention.
18
+
19
+ - **The recommended profile gives up "correct it without a release", deliberately** — it followed a branch because its case for immutability was the opposite of the festival art's: a profile fails LOUDLY (the user is looking at the button and gets an error naming the missing slot) where a holiday fails silently on a day nobody is watching, so pinning bought nothing there and a branch bought the ability to fix a bad recommendation by editing one JSON file. That is what is traded away. A branch also bought a plugin whose bytes could change under a user between the build and the click, and with one tag covering the screenshots, the art and the profile, no fetched byte can do that any more. The price is named rather than left to be discovered: correcting one JSON file now costs a tag in `assets`, a new value in `src/index.ts` and a release on npm — and a tag is not a commit. It **can** be re-pointed, while jsDelivr serves a version reference as immutable, so re-pointing one takes a purge request instead of a push, which makes a correction a new tag rather than a force-push of the old one. That is exactly why `presetKey` survives this change: an edge that has not been purged can still answer with the previous bytes of the same URL, so two fetches of "the profile" are still not guaranteed to be one revision, and a press that resumes must not glue them together.
20
+
21
+ - **Gates** — `check:node` had pinned the old design in two assertions, and its `revision()` helper could not see a tag at all: it matched a 40-character SHA, which is the only reason the raw mirror's reference could be lifted out without an `@`. It now matches beside the repository name (`HarmlessFunny/assets@<ref>` in the jsDelivr shape, `HarmlessFunny/assets/<ref>/` in the raw one), so the two mirrors stay comparable, and both assertions require `v0.1.0` — the holiday art's became "pinned to the assets release tag, never to a branch" and the profile's became "read at the assets release tag, not at a branch", replacing the assertion that used to pin the branch on purpose. `pnpm test` is ten checks, all passing, and `lib/` is that run's output.
4
22
 
5
23
  ## v0.7.8
6
24
 
package/CHANGELOG.zh.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # 更新日志
2
2
 
3
- > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.7.8 / v0.7.7)。
3
+ > 这里保留完整的历史记录;[README.zh.md](./README.zh.md#近期优化) 的「近期优化」一节只列最近两个版本(v0.7.10 / v0.7.9)。
4
+
5
+ ## v0.7.10
6
+
7
+ - **节日画的缓存现在按插件所钉的发行版做键,于是更新过的插件拿到的是重新下载,而不是上一代的字节** —— 缓存此前住在 `holiday-cache/<asset>`,而 `readHolidayAsset` 只要那个文件存在且是一张合法图片就直接拿它作答(`const cached = await readFileOrNull(holidayCachePath(asset))`,然后 `if (cached !== null && imageMimeOrNull(cached) !== null) return cached`):不比版本、不联网、不留日志。于是把一个构建指向 assets 仓库的新发行版,改变的是它**本会去取**的 URL,而它**实际返回**的东西一点没变 —— 因为那份旧字节仍然躺在 `mid-autumn.webp` 上。这正是「钉死引用」本身要防的那种**静默**失效(画在没人盯着的那一天停止更新),只是发生在引用之下一层,钉引用够不到的地方。现在 `holidayCachePath` 带上了 tag(`holiday-cache/v0.1.0/mid-autumn.webp`),发行版成了文件**身份**的一部分:钉了新 tag 的构建看的是一个没人写过的目录,更新之后遇到的第一个节日是一次缓存未命中,而 `ls holiday-cache/` 能回答「这张盘上留着哪几代」。
8
+
9
+ - **被取代的那一代会被清掉,而且是在新图落盘之后、绝不在之前。** 光有带版本的键会让每一代都永远留在那里 —— 每代约 730 KB(228 KB + 500 KB),还包括后续发行版已经彻底不再发布的素材,而那些东西没有别的机制会去删。`pruneHolidayCache` 在一次成功的缓存写入**末尾**、rename 之后运行:一个取不到的发行版不该让用户丢掉他已经有的画,而一次失败的下载也没有资格去删任何东西。它遍历缓存根目录,删掉名字不是本构建 tag 的**目录**条目 —— 它比较的是代号而不是素材名,所以正在用的那一代无论比较结果如何都不可能被碰到 —— 而且它是尽力而为:一个清不掉的缓存只是多了一个目录,为这件事让一个节日失败是本末倒置。`resetStore` 依然不动任何一代:那份缓存不是用户的配置,清理也不是它的职责。
10
+
11
+ - **门禁** —— `check:node` 此前把 tag 写在两条 URL 断言里、缓存路径又是第三个字面量,现在测试脚本定义了一个 `REF`,两条断言和 `GEN = join(CACHE, REF)` 都读它。新增三条断言:所钉那一代里缺失的素材会被重新下载而不是读到旧的、种下的 `v0.0.9` 目录在新图落盘后消失、以及随后正在用的那一代里两张素材都还在。损坏缓存条目、镜像回落、并发冷读取那几节改成指向代号目录,结果不变。`pnpm test` 十道全过,`lib/` 就是那一次运行的产物。
12
+
13
+ ## v0.7.9
14
+
15
+ - **所有素材引用都钉到 assets 仓库的同一个发行 tag(`v0.1.0`),此前漂开的三处现在一致了** —— README 的三张截图读的是 `main` 分支,节日素材钉的是一个原始 commit 哈希(`HOLIDAY_ASSETS_COMMIT = '58a9e107…'`),而推荐配置是**刻意**跟着 `main` 的。三处引用,对「这个构建到底在看哪一份字节」给出了三种不同答案,而其中只有一处真的对仓库的变动免疫。现在 `HOLIDAY_ASSETS_COMMIT` 变成了 `HOLIDAY_ASSETS_REF = 'v0.1.0'`(值和名字一起改,因为它指的已经不是一个 commit),`PRESET_ASSETS_REF` 用同一个字符串,于是 `HarmlessFunny/assets` 的一个发行版就是插件所取的每一个字节的同一个版本 —— 节日素材、推荐配置和 README 的图片一起 —— 而且这个值人能读、能拿去和 GitHub 上的 tag 对照、也能在一处改完,而不是一个没人能对上日期的 40 位哈希。
16
+
17
+ - **节日素材的字节并没有跟着改名而变。** `58a9e107` 是 `v0.1.0` 的祖先,两张 `dsh-background-by-model/holiday/*.webp` 在这两个修订之间**逐字节相同**(blob 都是 `46ccfc89…` 和 `dc5080e4…`),所以下载还是同一次下载 —— 两个修订之间的差异是推荐配置到位(七个 `preset/modelbg-*`、`preset.json` 和 `theme-config.json`,这些在那个 commit 上还不存在)加上三张截图重拍。真正变了的是 raw 镜像也带上了 tag(`raw.githubusercontent.com/HarmlessFunny/assets/v0.1.0/…`),这才让「两个镜像服务同一个修订」是按**值**成立、而不是靠约定。
18
+
19
+ - **推荐配置是刻意放弃「不发版就能修」的** —— 它此前跟着分支,因为它要求不可变的理由和节日素材正好相反:推荐配置的失败是**响亮的**(用户正盯着那个按钮,会拿到一个点名缺哪个槽位的错误),而节日是在没人盯着的那一天静默失效;所以钉版本在这里什么都买不到,而分支买到了「改一个 JSON 就能修好一个不好的推荐」。现在把这个交易换掉了。分支同时还买来了「插件的字节可能在用户装上之后、点下去之前发生变化」;现在一个 tag 同时覆盖截图、节日素材和这份配置,所取的每一个字节都不会再这样。代价是写明的而不是等着被发现的:改一个 JSON 现在要花一个 `assets` 里的 tag、`src/index.ts` 里的一个新值,以及一次 npm 发版 —— 而 tag 不是 commit。它**可以**被重新指向,而 jsDelivr 把版本引用按不可变处理,所以重新指向要发一次 purge 请求而不是 push,于是「修正」的形态是发一个新 tag,而不是强推旧的。这也正是 `presetKey` 在这次改动后仍然保留的原因:一个没清过缓存的边缘节点仍然会回同一个 URL 的上一次字节,所以两次取「这份配置」依然不保证是同一个修订,恢复下载的那一次不能把两个修订拼在一起。
20
+
21
+ - **门禁** —— `check:node` 里有两条断言钉的是旧设计,而它的 `revision()` 辅助函数根本看不见 tag:它匹配的是 40 位 SHA,而这也正是 raw 镜像的引用当年能不靠 `@` 被取出来的唯一原因。现在它从仓库名旁边匹配(jsDelivr 形状是 `HarmlessFunny/assets@<ref>`,raw 形状是 `HarmlessFunny/assets/<ref>/`),于是两个镜像仍然可以互相比较;两条断言都要求 `v0.1.0` —— 节日素材那条变成「pinned to the assets release tag, never to a branch」,推荐配置那条变成「read at the assets release tag, not at a branch」,取代了此前**故意**钉住分支的那一条。`pnpm test` 十道全过,`lib/` 就是那一次运行的产物。
4
22
 
5
23
  ## v0.7.8
6
24
 
package/README.md CHANGED
@@ -49,19 +49,19 @@ Notes:
49
49
  One interface, one config — only the current model differs:
50
50
 
51
51
  <p align="center">
52
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-deepseek.webp" alt="Appearance under DeepSeek" width="880">
52
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/wallpaper-deepseek.webp" alt="Appearance under DeepSeek" width="880">
53
53
  <br/>
54
54
  <em>DeepSeek V4.1 Flash · matched the rule whose match string contains <code>deepseek</code>: a light wallpaper with a matching theme color</em>
55
55
  </p>
56
56
 
57
57
  <p align="center">
58
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-kimi.webp" alt="Appearance under Kimi" width="880">
58
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/wallpaper-kimi.webp" alt="Appearance under Kimi" width="880">
59
59
  <br/>
60
60
  <em>Kimi K2.7 Code · matched the rule containing <code>kimi</code>: a dark wallpaper, a dark theme, and the whole interface reskinned with it</em>
61
61
  </p>
62
62
 
63
63
  <p align="center">
64
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/rule-editor.webp" alt="One rule's editor" width="660">
64
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/rule-editor.webp" alt="One rule's editor" width="660">
65
65
  <br/>
66
66
  <em>One rule on the Model Background tab · the wallpaper, its opacity and its blur belong to the rule; the layout mode, the framing and the theme color (wheel / HSL / RGB / extract from this image / pick from image) belong to the image it is editing</em>
67
67
  </p>
@@ -120,8 +120,8 @@ A small one, not a settings block: **one switch on the Config page and nothing e
120
120
  - **National Day** — October 1–7.
121
121
  - **Mid-Autumn wins the overlap** — the two do collide (2025-10-06 was both); the single precise day is the better answer.
122
122
  - **A holiday with no image falls through** to the model rules rather than blanking the wallpaper.
123
- - **The art is downloaded, not shipped** — a compressed wallpaper per holiday (228 KB and 500 KB at native resolution, against 8.37 MB of source art), hosted in the project's [assets repository](https://github.com/HarmlessFunny/assets) rather than in the package: they were two thirds of the tarball for a picture that is looked at one day a year. The download is gated on the **calendar**, so a profile that is not on a holiday fetches nothing at all, and the first read on the day caches the file under `holiday-cache/` — every boot after that, and every year after that, is answered from disk and needs no network. The reference is pinned to a **commit**, never a branch, so reshuffling the repository's screenshots cannot break a shipped festival; a `jsDelivr` mirror is tried first and `raw.githubusercontent.com` second, and if both are unreachable the holiday simply does not take over — the model rules keep painting.
124
- - **Neither the art nor the palette is swappable, by design** — an easter egg that asks to be configured is not an easter egg. A holiday slot is read-only: a file dropped into `modelbg-h-midautumn` / `modelbg-h-nationalday` is ignored by every read, and the plugin refuses to write, fetch into or delete those slots. The download lands in `holiday-cache/`, keyed by asset name and never by slot, so the store still never holds a `modelbg-h-*` file. The theme colors are constants in the holiday definition and are forced on every read — on the holiday **and on its image**, since a theme color lives on the image now — so a hand-edited `color` in `theme-config.json` cannot repaint a festival either. Its layout mode is the definition's **Fill** for the same reason (the art is full-bleed, and a stale `fit` would letterbox it), while the **framing** next to it stays yours. Theme exports therefore carry your rules only.
123
+ - **The art is downloaded, not shipped** — a compressed wallpaper per holiday (228 KB and 500 KB at native resolution, against 8.37 MB of source art), hosted in the project's [assets repository](https://github.com/HarmlessFunny/assets) rather than in the package: they were two thirds of the tarball for a picture that is looked at one day a year. The download is gated on the **calendar**, so a profile that is not on a holiday fetches nothing at all, and the first read on the day caches the file under `holiday-cache/<tag>/` — **keyed by the release the plugin pins**, so a build that pins a new tag is answered by a fresh download instead of by the previous generation's bytes, and every boot after that, and every year after that, is answered from disk and needs no network. The reference is pinned to a **release tag** (`v0.1.0`), never a branch, so reshuffling the repository's screenshots cannot break a shipped festival; a `jsDelivr` mirror is tried first and `raw.githubusercontent.com` second, and if both are unreachable the holiday simply does not take over — the model rules keep painting.
124
+ - **Neither the art nor the palette is swappable, by design** — an easter egg that asks to be configured is not an easter egg. A holiday slot is read-only: a file dropped into `modelbg-h-midautumn` / `modelbg-h-nationalday` is ignored by every read, and the plugin refuses to write, fetch into or delete those slots. The download lands in `holiday-cache/<tag>/`, named after the asset and never after a slot, so the store still never holds a `modelbg-h-*` file. The theme colors are constants in the holiday definition and are forced on every read — on the holiday **and on its image**, since a theme color lives on the image now — so a hand-edited `color` in `theme-config.json` cannot repaint a festival either. Its layout mode is the definition's **Fill** for the same reason (the art is full-bleed, and a stale `fit` would letterbox it), while the **framing** next to it stays yours. Theme exports therefore carry your rules only.
125
125
  - **Turning it on** — the switch on the Config page, or `"holidays": { "enabled": true }` in the same file. Only a real `true` counts: the feature is on because a configuration says so, not because nothing has been said. The per-holiday switches inside it (中秋 / 国庆) are a different thing and still default to on — they are details of a feature that is already switched on.
126
126
 
127
127
  ### Per-rule and per-image settings
@@ -161,12 +161,12 @@ So it is editable where it lives: the config is a few kilobytes of readable JSON
161
161
  It is the only action in the panel that **destroys** something, and it is built around that:
162
162
 
163
163
  - **You are asked second, not first.** The download happens before the confirmation, so the question can say what is actually in hand — *7 rules · 7 wallpapers · 2.9 MB* — instead of asking about a file that may never arrive. If the download fails, nothing has happened yet.
164
- - **One missing wallpaper fails the whole thing**, and says which. A profile that quietly arrived without a picture — because a file was renamed where it is hosted — would be exactly the silent failure that pinning the festival art to a commit exists to prevent; naming the file keeps that failure loud, which is what makes a branch reference acceptable here.
165
- - **Every file retries on its own, and a failed attempt keeps what it fetched.** A file used to get exactly two chances (one per mirror), so with the first few wallpapers down and the next hitting a 502 or a CDN hiccup the whole profile failed and the button started from zero again. Each file now gets up to three rounds (400 ms and then 800 ms apart), while three failures are not retried at all — "the file is not there" (the same answer twice), "the bytes are over the cap" (the same bytes again), and "a full minute of silence" (both mirrors were already asked inside that same round, which is what keeps the worst case at three minutes rather than six). What did arrive is kept for the next press, so that press only fetches what is missing — as long as the config it re-fetches is **the same value** as the one those bytes came from (this profile is served from a branch, and two different pushes must not be glued together). The strictness is unchanged: the first file that cannot be had still fails the whole attempt, and still says which one.
164
+ - **One missing wallpaper fails the whole thing**, and says which. A profile that quietly arrived without a picture — because a file was renamed where it is hosted — would be exactly the silent failure that pinning the festival art to a release tag exists to prevent; naming the file keeps that failure loud, and that loudness is what makes a profile safe to read from a host this plugin does not control.
165
+ - **Every file retries on its own, and a failed attempt keeps what it fetched.** A file used to get exactly two chances (one per mirror), so with the first few wallpapers down and the next hitting a 502 or a CDN hiccup the whole profile failed and the button started from zero again. Each file now gets up to three rounds (400 ms and then 800 ms apart), while three failures are not retried at all — "the file is not there" (the same answer twice), "the bytes are over the cap" (the same bytes again), and "a full minute of silence" (both mirrors were already asked inside that same round, which is what keeps the worst case at three minutes rather than six). What did arrive is kept for the next press, so that press only fetches what is missing — as long as the config it re-fetches is **the same value** as the one those bytes came from (this profile is read at one version of the assets repository, but that version's tag can be re-pointed and an edge that has not been purged will still answer with the previous bytes, so two different revisions must not be glued together). The strictness is unchanged: the first file that cannot be had still fails the whole attempt, and still says which one.
166
166
  - **The store is emptied before the profile is written.** `nextSlot` allocates slots as `m1`, `m2`, … — a counter, not a random token — so a downloaded profile and the store it lands in collide on those names as a matter of course. "Replace everything with this" is only true if the everything is genuinely gone, and this is also why there is no undo.
167
167
  - **The wipe runs after the download, never before it.** An unreachable CDN must not cost you your configuration on the way to a profile that never arrived — which matters more now that the download is several transfers rather than one.
168
168
  - **The festival art is not touched.** `holiday-cache/` is downloaded art that belongs to no slot; it is not part of anybody's configuration, and re-fetching it would be a network round trip for nothing. The config *does* name the two holiday slots (they are part of any config), and they are dropped from the request set rather than asked for: no `modelbg-h-*` file can exist.
169
- - **It is referenced by branch, unlike the festival art's commit** — and for the opposite reason. A profile fails *loudly* (you are looking at the button when it happens) where a holiday fails silently, so pinning would buy nothing and would cost a plugin release for every correction to one JSON file. The risk that is real — a profile written for a config shape this build cannot read — is what `preset.json` is for: it is refused before a single wallpaper is requested, and long before anything is deleted.
169
+ - **It is referenced at the same release tag as the festival art** — and it used to be a branch, deliberately: a profile fails *loudly* (you are looking at the button when it happens) where a holiday fails silently, so pinning bought nothing there and a branch let a bad recommendation be corrected without a plugin release. What a branch also bought was a plugin whose bytes could change under you between the install and the click; with one tag covering the screenshots, the festival art and this profile, one release of the assets repository is now one version of everything the plugin fetches. The price is that correcting one JSON file costs a tag in `assets`, a value in `src/index.ts` and a release on npm — and that moving a tag does not reach jsDelivr's edge by itself (the reference is served as immutable, and the purge tool is the only way to re-point it), so a correction is a new tag rather than a force-push of the old one. The risk that is real — a profile written for a config shape this build cannot read — is what `preset.json` is for: it is refused before a single wallpaper is requested, and long before anything is deleted.
170
170
 
171
171
  ## Settings
172
172
 
@@ -188,7 +188,7 @@ Data directory: `~/.dsh/.dsh-background-by-model-data/` (Windows: `C:\Users\<you
188
188
  | --- | --- |
189
189
  | `theme-config.json` | The rule list (each rule with its image list and rotation) plus the global Interface settings, the global switch effect and the holiday overrides |
190
190
  | `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 |
191
- | `holiday-cache/<asset>` | Festival art downloaded from the CDN, one file per holiday (`mid-autumn.webp`, `national-day.webp`). Keyed by **asset name**, deliberately outside the slot namespace — a holiday slot still never holds a file, which is what keeps it read-only. Delete a file here and it is simply re-downloaded the next time that holiday is in force |
191
+ | `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`). That directory is the point: a cache keyed by asset name alone would answer an updated plugin with the **previous** generation's bytes and say nothing about it, so the release is part of the path and a build pinning a new tag starts from an empty directory. Inside it the file is named after the **asset**, deliberately outside the slot namespace — a holiday slot still never holds a file, which is what keeps it 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 |
192
192
 
193
193
  ## Installation
194
194
 
@@ -251,7 +251,7 @@ After changing anything under `src/`, run `pnpm run bundle` again — the mounte
251
251
  - **`check:rules`** — the two decisions behind the tab strip, which are pure and live in `src/client/rules-view.ts` (which rule the editor shows, and where a deletion lands — its neighbour, never rule 1), plus the page structure the layout rests on: one editor drawn, no stacked per-card container, no per-card open state, a strip that can neither wrap nor grow, and its scrollbar block staying scoped to the plugin's own classes.
252
252
  - **`check:preset`** — the two decisions behind the recommended profile's transfers (`src/preset.ts`: which failures are worth another round, and what a failed attempt leaves for the next one), plus the two halves actually using them. Each of the six failure reasons is judged on its own (the three terminal ones are the three that can be decided locally and cheaply, and a timeout is not retried because both mirrors were already asked inside the same round); the wait grows and stays bounded; two key orders of one value are one revision while the image list's ORDER counts (it is the rotation); and a resume hands back a copy rather than the held record itself. The wiring is pinned with it: the node half looping over the round budget and asking the policy, the client sizing its pool by what is missing rather than by the whole slot list, holding what arrived on failure and clearing it on success, and applying the size cap **before** a file is kept.
253
253
  - **`check:ui`** — every `t('…')` key exists in both dictionaries, both dictionaries carry the same key set, every `dab-…` class has a rule in the stylesheet, no dictionary entry has gone unreferenced, and every `var(--dsw-…)` the UI reads is a token the installed theme actually publishes — an undefined custom property drops the declaration and the property quietly falls back to an inherited value, which is how a placeholder once painted in the ink of real text (with the theme bundle absent, that section skips with a notice).
254
- - **`check:node`** — the built node half driven through its real RPC handler with `DSH_HOME` redirected to a throwaway directory, and `globalThis.fetch` replaced by a stub — the festival art lives on a CDN now, and this suite must not depend on one being up, nor assert today's bytes of a file it does not own. It covers: the holiday block through the shared sanitizer; nothing fetched merely for loading the plugin or reading the config; the art fetched on demand and cached under `holiday-cache/` keyed by **asset** rather than slot; a second read answered from disk with no second transfer; two simultaneous cold reads sharing one transfer; a dead first mirror falling through to the second with both mirrors pinned to the same commit; every mirror down yielding "no bytes" rather than a throw, with nothing cached from a failed fetch; a mirror answering 200 with a non-image rejected rather than cached; a corrupt cache entry discarded and re-downloaded; a holiday slot refusing writes, deletes and URL fetches while a file dropped into it is ignored and no `modelbg-h-*` file is ever written; an ordinary rule slot still writing, listing and deleting; a hand-edited config unable to redirect a holiday at another rule's image; the per-image color shape — the read-time lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors through the file, and a `null` written as a key rather than omitted; and the global switch effect — the shipped default for a config that predates it, a chosen effect/easing/unified duration surviving the round trip, hostile values falling back while an absurd duration clamps, a `0` duration kept rather than defaulted, and drift inside `transition` reported instead of silently dropped. The recommended profile is covered end to end from the node half's side: the manifest and the config fetched in that order and sanitized on arrival, the request set DERIVED from the rules with the holiday slots dropped from it, one request per wallpaper with the bytes sniffed rather than trusted from a header, a dead jsDelivr falling through to raw for a single file, the same file **asked again** when both mirrors stumble at once and arriving on that third request (three entries in the log), a file the host does not have still costing exactly two requests (one per mirror, no waiting), a permanently broken mirror costing exactly six (three rounds over two mirrors — the retry has a budget), and every way it can fail reported as a value rather than a throw — every mirror down, a mirror answering 500, a 200 carrying HTML, a manifest whose version is not a number, a profile written for a newer shape refused before a single wallpaper is asked for, a config that is not JSON, a config that is a JSON array, a config naming more images than the cap, and a mirror announcing an oversized body; plus the strict half (a named wallpaper the host does not have is an error, not a short profile), the two naming refusals that happen before any request at all (a holiday slot, and a slot name that is not a slot), and the reset: the config and every rule image gone, `holiday-cache/` untouched, a read afterwards yielding the same one blank rule rather than an error, and the profile then writing into the emptied store — with the fresh-install rule itself pinned first: a data directory with no config file reads as exactly one blank rule (`r1`, no match string, no colour, no image), while a rule list written explicitly empty still reads back empty, because those are two different answers.
254
+ - **`check:node`** — the built node half driven through its real RPC handler with `DSH_HOME` redirected to a throwaway directory, and `globalThis.fetch` replaced by a stub — the festival art lives on a CDN now, and this suite must not depend on one being up, nor assert today's bytes of a file it does not own. It covers: the holiday block through the shared sanitizer; nothing fetched merely for loading the plugin or reading the config; the art fetched on demand and cached under `holiday-cache/<pinned tag>/` — keyed by the **release** and by **asset**, never by slot, with a superseded generation dropped once the new art is on disk; a second read answered from disk with no second transfer; two simultaneous cold reads sharing one transfer; a dead first mirror falling through to the second with both mirrors read at the same release tag; every mirror down yielding "no bytes" rather than a throw, with nothing cached from a failed fetch; a mirror answering 200 with a non-image rejected rather than cached; a corrupt cache entry discarded and re-downloaded; a holiday slot refusing writes, deletes and URL fetches while a file dropped into it is ignored and no `modelbg-h-*` file is ever written; an ordinary rule slot still writing, listing and deleting; a hand-edited config unable to redirect a holiday at another rule's image; the per-image color shape — the read-time lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors through the file, and a `null` written as a key rather than omitted; and the global switch effect — the shipped default for a config that predates it, a chosen effect/easing/unified duration surviving the round trip, hostile values falling back while an absurd duration clamps, a `0` duration kept rather than defaulted, and drift inside `transition` reported instead of silently dropped. The recommended profile is covered end to end from the node half's side: the manifest and the config fetched in that order and sanitized on arrival, the request set DERIVED from the rules with the holiday slots dropped from it, one request per wallpaper with the bytes sniffed rather than trusted from a header, a dead jsDelivr falling through to raw for a single file, the same file **asked again** when both mirrors stumble at once and arriving on that third request (three entries in the log), a file the host does not have still costing exactly two requests (one per mirror, no waiting), a permanently broken mirror costing exactly six (three rounds over two mirrors — the retry has a budget), and every way it can fail reported as a value rather than a throw — every mirror down, a mirror answering 500, a 200 carrying HTML, a manifest whose version is not a number, a profile written for a newer shape refused before a single wallpaper is asked for, a config that is not JSON, a config that is a JSON array, a config naming more images than the cap, and a mirror announcing an oversized body; plus the strict half (a named wallpaper the host does not have is an error, not a short profile), the two naming refusals that happen before any request at all (a holiday slot, and a slot name that is not a slot), and the reset: the config and every rule image gone, `holiday-cache/` untouched, a read afterwards yielding the same one blank rule rather than an error, and the profile then writing into the emptied store — with the fresh-install rule itself pinned first: a data directory with no config file reads as exactly one blank rule (`r1`, no match string, no colour, no image), while a rule list written explicitly empty still reads back empty, because those are two different answers.
255
255
 
256
256
  ## FAQ
257
257
 
@@ -302,19 +302,20 @@ Two things can hold it, and both are on the card: the effect is **Instant**, or
302
302
 
303
303
  ## Recent Optimizations
304
304
 
305
- > Only the two most recent releases are listed here; older ones (v0.7.6 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
305
+ > Only the two most recent releases are listed here; older ones (v0.7.8 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
306
306
 
307
- ### v0.7.8
307
+ ### v0.7.10
308
308
 
309
- - **A fresh install opens on one blank rule instead of the "no rules yet" screen** — with no config file at all the read used to produce an empty rule list, so the first thing a new user saw was "No rules yet — use 'Add rule' in the tab strip above": the panel telling them to press a control it had not given them. "The `rules` key is not there" (nothing has ever been configured) and "the key is there and the array is empty" (the user deleted every rule) are two different answers now, and only the first is filled in with one blank rule (id `r1`, so it is rule 1, the fallback, and the tab the panel selects); the second stays empty, so deleting the last rule is not undone by the next read. The rule is deliberately blank rather than half-built: with no picture and no theme colour of its own, matching skips it until something is filled in, instead of letting an empty background take the fallback away from rule 1.
310
- - **The placeholder no longer looks like real text** — its `::placeholder` colour named `--dsw-alias-label-quaternary`, which is defined NOWHERE in this harness's theme: an undefined custom property drops the declaration at computed-value time, and `color` is inherited, so the placeholder fell back to the input's own `label-primary` — the brightest ink in the panel, inside a box nobody had typed in. All three inputs that carry one (the rule's match string, the image URL, the rotation's custom seconds) now read `--dsw-alias-label-caption`, the weak token the plugin already uses for input placeholders; two more dead names went with it (`.dab-pick-n`'s `--dsw-mono` was missing a letter — the plugin's own variable is `--dab-mono` — and `.dab-brand-tag`'s carried a fallback, so dropping it changes no colour).
311
- - **The recommended profile retries per file, and a failed attempt keeps what it fetched** — a file used to get exactly two chances (one per mirror), so with the first few wallpapers down and the next hitting a 502 or a CDN hiccup the whole profile failed and pressing the button again started from zero. Every file now gets up to three rounds, 400 ms and then 800 ms apart; three failures are not retried — "the file is not there" (the same answer twice), "the bytes are over the cap" (the same bytes again), and "a full minute of silence" (both mirrors were already asked inside that same round, and this is what keeps the worst case at three minutes rather than six) — while everything else gets another round. What did arrive stays in hand, so the next press fetches only what is missing, as long as the config it re-fetches is **the same value** as the one those bytes came from (the profile is served from a branch, and two different pushes must not be glued together). The confirmation, the button's copy and where the progress sits are untouched; **so is the strictness** — the first file that cannot be had still fails the whole attempt and still names the slot.
309
+ - **The festival art's cache is keyed by the pinned release, so an updated plugin re-downloads instead of being answered with the old bytes** — the cache lived at `holiday-cache/<asset>`, which made the file's identity independent of the bytes behind it: point the plugin at a new release of the assets repository and `readHolidayAsset` still found `mid-autumn.webp` on disk, returned it, and said nothing. That is the same silent failure the pinned reference exists to prevent, one layer down — and it is invisible by construction, on a day nobody is watching. The cache is now `holiday-cache/<tag>/<asset>` (`holiday-cache/v0.1.0/mid-autumn.webp`), so the tag is part of the path and a build pinning a new tag starts from a directory nobody has written to. `ls holiday-cache/` now answers "which generations are on this disk".
310
+ - **The generation it supersedes is dropped, after the new art is on disk and never before it.** A versioned key would otherwise leave every previous generation lying there forever (about 730 KB each), including the assets a later release stops shipping at all. Pruning happens at the end of a successful cache write — the bytes are already in hand and renamed into place — so a pinned release that turns out to be unreachable cannot cost a user the art they already had. It removes **directory** entries and only those whose name is not this build's tag, so the generation in use cannot be touched however the comparison goes.
311
+ - **`check:node` pins both halves of it.** The suite's `REF` is now one value used by the two URL assertions *and* by the cache path, and three assertions were added: art missing from the pinned generation is re-downloaded rather than read stale, a planted `v0.0.9` directory is gone once the new art lands, and the generation in use is left exactly where it was.
312
312
 
313
- ### v0.7.7
313
+ ### v0.7.9
314
314
 
315
- - **A fresh install starts on the author's own profile, with nothing to download** — the shipped Interface numbers are now the ones the [recommended profile](#recommended-profile) carries: main background `0%`, left panel `10%`, right panel `45%`, code plate `81%`, inputs `88%` with `60 px` of blur, conversation text `35%`, trajectory `73%`. The plugin therefore looks right before a byte is fetched, and the profile's one click adds pictures rather than changing the look underneath them. Every one of those numbers used to be picked per key for **safety** rather than for looks — `bg` 0.85 so the host surface stayed visible under it, `code` fully opaque because a code block is read rather than looked through, `input` blur 0 because blur costs a compositor layer — which is why a new install opened on a pale approximation of the host instead of on a configuration anyone would run. The preview panel is the one field that needed care: `null` ("never dragged, so follow the main background") is preserved as itself rather than read as a missing key, because every release so far has **written** it — a panel the user never dragged sits on disk as an explicit `null`, and taking that for "missing" would have repainted the panel in every existing configuration.
316
- - **The festival switch ships off** — a first run paints what the model rules say and nothing else; the switch on the Config page is how the feature is asked for, and the recommended profile turns it on, which is the difference between a chosen configuration and a fresh one. Only a real boolean counts now: an absent field, or a hand-edit that is not `true`/`false`, takes the shipped default instead of the old "anything but `false` means on".
317
- - **The percentage pill in each Interface card is gone** — the number was already on the slider row below it, so the card head is the icon and the name again.
315
+ - **Every asset reference is pinned to one release tag of the assets repository** — `v0.1.0`. Three references had drifted into three different states: the README's three screenshots were read from the `main` branch, the festival art was pinned to a raw commit hash (`58a9e107…`) that said nothing to a human, and the recommended profile followed `main` on purpose, so a plugin could be looking at bytes that changed after it was built. All three are now `…/assets@v0.1.0/…`, which is one version of the assets repository for every byte the plugin fetches — screenshots, festival art and the recommended profile together — and a value a person can read, compare against the tags on GitHub and update in one place.
316
+ - **The festival art's bytes do not move with it.** `58a9e107` is an ancestor of `v0.1.0` and the two `holiday/*.webp` files are byte-identical between them, so the download is unchanged; what changed is that the reference is now named the way the repository is named. The raw mirror carries the tag too (`raw.githubusercontent.com/HarmlessFunny/assets/v0.1.0/…`), so the fallback still cannot mix revisions.
317
+ - **The recommended profile deliberately gives up "fix it without a release"** — it used to follow a branch so a bad recommendation could be corrected in the assets repository instead of waiting for a plugin release, and the reasoning was that a profile fails *loudly* (you are looking at the button) where a holiday fails silently, so immutability bought nothing there. What it cost was a plugin whose bytes could change under you between the install and the click; with one tag covering the screenshots, the art and the profile, that is no longer true of any of them. Correcting one JSON file now costs a tag in `assets`, a value in `src/index.ts` and a release on npm. Note what a tag is **not**: it can be re-pointed, and jsDelivr serves a version reference as immutable, so re-pointing one means a purge request rather than a push — which is why `presetKey` survives this change, since an edge that has not been purged can still answer with the previous bytes of the same URL, and a press that resumes must not glue two revisions together.
318
+ - **`check:node` pins the tag on both features.** Its `revision()` helper used to look for a 40-character SHA, which cannot see a tag at all; it now lifts the reference out beside the repository name (`HarmlessFunny/assets@<ref>` in the jsDelivr shape, `HarmlessFunny/assets/<ref>/` in the raw one) so the two mirrors can still be compared, and the two assertions that used to read "a commit, never a branch" and "follows the branch" now both require `v0.1.0`.
318
319
 
319
320
  ## Star History
320
321
 
package/README.zh.md CHANGED
@@ -49,19 +49,19 @@
49
49
  同一套界面、同一份配置,只因为当前模型不同:
50
50
 
51
51
  <p align="center">
52
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-deepseek.webp" alt="DeepSeek 下的外观" width="880">
52
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/wallpaper-deepseek.webp" alt="DeepSeek 下的外观" width="880">
53
53
  <br/>
54
54
  <em>DeepSeek V4.1 Flash · 命中匹配串含 <code>deepseek</code> 的规则:浅色壁纸 + 与图相配的主题色</em>
55
55
  </p>
56
56
 
57
57
  <p align="center">
58
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-kimi.webp" alt="Kimi 下的外观" width="880">
58
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/wallpaper-kimi.webp" alt="Kimi 下的外观" width="880">
59
59
  <br/>
60
60
  <em>Kimi K2.7 Code · 命中匹配串含 <code>kimi</code> 的规则:深色壁纸 + 深色主题,整个界面跟着换肤</em>
61
61
  </p>
62
62
 
63
63
  <p align="center">
64
- <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/rule-editor.webp" alt="单条规则的编辑器" width="660">
64
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@v0.1.0/dsh-background-by-model/rule-editor.webp" alt="单条规则的编辑器" width="660">
65
65
  <br/>
66
66
  <em>「模型背景」页的单条规则 · 壁纸、透明度与模糊度属于这条规则;布局模式、取景与主题色(色轮 / HSL / RGB / 从本图提取 / 从图片取色)属于它正在编辑的那张图</em>
67
67
  </p>
@@ -120,8 +120,8 @@
120
120
  - **国庆节** — 公历 10 月 1–7 日。
121
121
  - **两者重叠时中秋优先** — 它们确实会撞(2025-10-06 就是同一天),一个精确的单日比七天窗口里的某一天更值得采信。
122
122
  - **没有图的节日会回落到模型规则**,而不是把背景刷空。
123
- - **画是下载来的,不是随包发的** — 每个节日一张压缩过的壁纸(原生分辨率下 228 KB 与 500 KB,原图共 8.37 MB),放在项目自己的 [assets 仓库](https://github.com/HarmlessFunny/assets)里,而不是打进包里 —— 它们占了整个 tarball 的三分之二,而这张画一年只被看一天。下载由**日历**把关:不在节日的配置一点流量都不会有;当天第一次读取会把它缓存到 `holiday-cache/`,此后每一次启动、往后每一年,都直接读磁盘、不再联网。引用钉死在一个 **commit** 上而不是分支,所以你以后整理仓库里的截图不可能弄坏一个已经发出去的节日;先走 `jsDelivr`、失败再走 `raw.githubusercontent.com`,两个都不通时这个节日就是不接管 —— 模型规则继续画,不会白屏。
124
- - **图片和配色都不可替换,这是刻意的** — 一个要求用户去配置的彩蛋就不是彩蛋了。节日槽位是只读的:往 `modelbg-h-midautumn` / `modelbg-h-nationalday` 里放文件不会被任何一次读取采用,插件也拒绝写入、抓取和删除这两个槽位。下载落进 `holiday-cache/`,按**素材名**而不是槽位命名,所以数据目录里依然永远不会出现 `modelbg-h-*` 文件。主题色是节日定义里的常量、每次读取都强制覆盖——**节日条目和它的图片两处都覆盖**,所以手改 `theme-config.json` 里的 `color` 也换不掉节日配色。布局模式同理固定为定义里的**填充**(这些画本来就是满幅的,一个残留的 `fit` 会给它加黑边),而旁边的**取景**仍然是你自己的。导出的主题文件只包含你自己的规则。
123
+ - **画是下载来的,不是随包发的** — 每个节日一张压缩过的壁纸(原生分辨率下 228 KB 与 500 KB,原图共 8.37 MB),放在项目自己的 [assets 仓库](https://github.com/HarmlessFunny/assets)里,而不是打进包里 —— 它们占了整个 tarball 的三分之二,而这张画一年只被看一天。下载由**日历**把关:不在节日的配置一点流量都不会有;当天第一次读取会把它缓存到 `holiday-cache/<tag>/` —— 路径里带**插件所钉的那个发行版**,于是一个换成新 tag 的构建拿到的是重新下载,而不是上一代的字节 —— 此后每一次启动、往后每一年,都直接读磁盘、不再联网。引用钉死在一个**发行 tag**(`v0.1.0`)上而不是分支,所以你以后整理仓库里的截图不可能弄坏一个已经发出去的节日;先走 `jsDelivr`、失败再走 `raw.githubusercontent.com`,两个都不通时这个节日就是不接管 —— 模型规则继续画,不会白屏。
124
+ - **图片和配色都不可替换,这是刻意的** — 一个要求用户去配置的彩蛋就不是彩蛋了。节日槽位是只读的:往 `modelbg-h-midautumn` / `modelbg-h-nationalday` 里放文件不会被任何一次读取采用,插件也拒绝写入、抓取和删除这两个槽位。下载落进 `holiday-cache/<tag>/`,文件名按**素材名**而不是槽位,所以数据目录里依然永远不会出现 `modelbg-h-*` 文件。主题色是节日定义里的常量、每次读取都强制覆盖——**节日条目和它的图片两处都覆盖**,所以手改 `theme-config.json` 里的 `color` 也换不掉节日配色。布局模式同理固定为定义里的**填充**(这些画本来就是满幅的,一个残留的 `fit` 会给它加黑边),而旁边的**取景**仍然是你自己的。导出的主题文件只包含你自己的规则。
125
125
  - **想要它** — 打开「配置」页那个开关,或者在同一个文件里写 `"holidays": { "enabled": true }`。只有真正的 `true` 算数:这个功能是「配置说了要」才开的,不是「没人说话」就默认开。它内部每个节日(中秋 / 国庆)各自那个开关是另一回事,仍然默认开着 —— 那是一个已经打开的功能内部的细节。
126
126
 
127
127
  ### 规则与图片各自的设置
@@ -161,12 +161,12 @@ preset/
161
161
  它同时也是这个面板里**唯一会销毁东西**的操作,整个设计都在围着这一点转:
162
162
 
163
163
  - **先下载,后询问。** 确认框出现在下载成功之后,所以它能如实说出手里到底有什么 —— *7 条规则 · 7 张壁纸 · 2.9 MB* —— 而不是让你先对「一个可能根本下不来的文件」做决定。下载失败时,什么都还没有发生。
164
- - **少一张壁纸就整体失败,并且会说是哪一张。** 一份悄悄少了图的配置 —— 因为托管处的某个文件被改了名 —— 正是「节日素材钉死 commit」那条契约要防的静默失效;把文件名报出来,这个失败就仍然是响亮的,而这正是分支引用在这里可以被接受的原因。
165
- - **每张图都会自己重试,而失败的那一次会把已经下到的部分留给下一次。** 一个文件此前只有两次机会(两个镜像各一次),前面几张都下好了、下一张碰上一次 502 或一次 CDN 抽风,整份配置就失败,再按一次按钮又是从零开始。现在每个文件最多三轮(轮间 400 ms 再 800 ms),而「这个文件不在那儿」「字节本身就超限」「六十秒都没吭声」三种失败不重试 —— 前两种再问也是同一个答案,第三种是因为两个镜像在同一轮里都已经问过了,这一条也正是把最坏情况钉在三分钟而不是六分钟的东西。已经拿到的字节会留到下一次,于是下一次只补缺的那几张;前提是重新取到的配置与那些字节来自的那一份**逐值相同**(这份配置服务在分支上,两次不同的推送不许拼在一起)。严格性没变:第一个拿不到的文件仍然让整次失败、仍然会说是哪一个。
164
+ - **少一张壁纸就整体失败,并且会说是哪一张。** 一份悄悄少了图的配置 —— 因为托管处的某个文件被改了名 —— 正是「节日素材钉死发行 tag」那条契约要防的静默失效;把文件名报出来,这个失败就仍然是响亮的,而这份响亮正是「配置可以从一个插件控制不了的托管处读取」可以被接受的原因。
165
+ - **每张图都会自己重试,而失败的那一次会把已经下到的部分留给下一次。** 一个文件此前只有两次机会(两个镜像各一次),前面几张都下好了、下一张碰上一次 502 或一次 CDN 抽风,整份配置就失败,再按一次按钮又是从零开始。现在每个文件最多三轮(轮间 400 ms 再 800 ms),而「这个文件不在那儿」「字节本身就超限」「六十秒都没吭声」三种失败不重试 —— 前两种再问也是同一个答案,第三种是因为两个镜像在同一轮里都已经问过了,这一条也正是把最坏情况钉在三分钟而不是六分钟的东西。已经拿到的字节会留到下一次,于是下一次只补缺的那几张;前提是重新取到的配置与那些字节来自的那一份**逐值相同**(这份配置读的是 assets 仓库的一个版本,但那个版本的 tag 可以被重新指向,而没有清过缓存的边缘节点仍会回上一次的字节,所以两个不同的修订不许拼在一起)。严格性没变:第一个拿不到的文件仍然让整次失败、仍然会说是哪一个。
166
166
  - **写入之前先清空。** `nextSlot` 分配槽位用的是 `m1`、`m2`…… 一个计数器而不是随机串,所以下载来的配置和它落进去的那个数据目录**必然**在这些名字上撞车。「用它替换一切」只有在那个「一切」真的没了的时候才成立 —— 这也是它没有撤销的原因。
167
167
  - **清空发生在下载之后,绝不会在之前。** CDN 不通不该让你在等一个永远没来的推荐配置的路上,先把自己的配置弄丢 —— 现在下载是多次传输而不是一次,这一点更重要。
168
168
  - **不碰节日素材。** `holiday-cache/` 是下载来的、不属于任何槽位的画,不是任何人配置的一部分;重下一遍只是白白一次网络往返。而 config **确实**会列出那两个节日槽位(任何一份 config 都有),它们会被从请求清单里剔掉而不是去请求:数据目录里永远不可能存在 `modelbg-h-*` 文件。
169
- - **它引用的是分支,而不是节日素材那样的 commit** —— 理由正好相反。推荐配置的失败是**响亮的**(文件下不来时你正盯着那个按钮),节日的失败却是静默的;所以钉 commit 在这里什么都买不到,反而会让「改一个 JSON 里的错别字」变成一次插件发版。真正存在的风险 —— 这份配置是写给一个本版本读不懂的配置形状的 —— 正是 `preset.json` 存在的理由:它在请求第一张壁纸之前、更在删除任何东西之前就被拒绝。
169
+ - **它和节日素材引用的是同一个发行 tag** —— 而它以前是分支,而且是刻意的:推荐配置的失败是**响亮的**(文件下不来时你正盯着那个按钮),节日的失败却是静默的,所以钉版本在这里什么都买不到,而分支能让一个不好的推荐不必等插件发版就能修正。但分支同时也买来了「插件的字节可能在装上之后、你点下去之前发生变化」;现在一个 tag 同时覆盖截图、节日素材和这份配置,assets 仓库的一个发行版就是插件所取的每一个字节的同一个版本。代价是:改一个 JSON 现在要花一个 `assets` 里的 tag、`src/index.ts` 里的一个值,以及一次 npm 发版;而且**移动**一个 tag 并不会自己抵达 jsDelivr 的边缘节点(版本引用是按不可变处理的,只有 purge 工具能重新指向它),所以修正是发一个新 tag,而不是强推旧的。真正存在的风险 —— 这份配置是写给一个本版本读不懂的配置形状的 —— 正是 `preset.json` 存在的理由:它在请求第一张壁纸之前、更在删除任何东西之前就被拒绝。
170
170
 
171
171
  ## 设置界面
172
172
 
@@ -188,7 +188,7 @@ preset/
188
188
  | --- | --- |
189
189
  | `theme-config.json` | 规则列表(每条规则含图片列表与轮换设置)+ 全局「界面」设置 + 全局切换效果 + 节日覆盖配置 |
190
190
  | `modelbg-<slot>` | 一张图片,保存为原始字节,无扩展名 —— 一张图一个文件,所以一条有三张图的规则占三个槽位 |
191
- | `holiday-cache/<asset>` | 从 CDN 下载的节日画,一个节日一个文件(`mid-autumn.webp`、`national-day.webp`)。按**素材名**命名,刻意放在槽位命名空间之外 —— 节日槽位里依然永远不会有文件,这正是它只读的原因。删掉这里的文件也没关系,下次那个节日生效时会重新下载 |
191
+ | `holiday-cache/<tag>/<asset>` | 从 CDN 下载的节日画,一个节日一个文件(`mid-autumn.webp`、`national-day.webp`),装在一个以**插件所钉的 assets 发行版**命名的目录里(`v0.1.0`)。这个目录是关键:只按素材名做键的缓存会把上一代的字节交给一个已经更新过的插件、而且什么都不说,所以发行版被放进路径里,换成新 tag 的构建从一个没人写过的目录开始。目录里的文件名仍然是**素材名**,刻意放在槽位命名空间之外 —— 节日槽位里依然永远不会有文件,这正是它只读的原因。被取代的旧代会在新图落盘之后被清掉。删掉这里的文件也没关系,下次那个节日生效时会重新下载 |
192
192
 
193
193
  ## 安装
194
194
 
@@ -251,7 +251,7 @@ pnpm run typecheck
251
251
  - **`check:rules`** — 选项卡背后那两个纯判定(在 `src/client/rules-view.ts`:编辑器显示哪条规则、删除之后落在哪——落在邻居上,永远不是规则 1),以及布局所依赖的页面结构:只画一个编辑器、没有旧的堆叠容器、没有逐卡片的展开状态、胶片条既不换行也不长高,以及它的滚动条样式块仍然被限定在插件自己的类上。
252
252
  - **`check:preset`** — 推荐配置那几次传输背后的两个判定(`src/preset.ts`:哪些失败值得再来一轮、失败的那一次给下一次留下什么),以及两侧**真的在用**它们:六个失败原因逐个判定(三个终态正是能在本地廉价判定的那三个;超时不重试是因为同一轮里两个镜像都已经问过)、等待指数增长且有上限、同一个值的不同键顺序给出同一个「同一份配置」而图片列表的顺序**算数**(它就是轮换顺序)、续传拿到的必须是副本而不是那条记录本身 —— 再加上接线(node 半边循环轮次并按策略决定、客户端按「还缺哪些」而不是整个槽位表铺并发池、失败留字节、成功清掉、体积上限在收下文件之前判)。
253
253
  - **`check:ui`** — 每个 `t('…')` 键在中英两本字典里都存在、两本字典键集完全一致、每个 `dab-…` 类在样式表里都有规则、没有字典条目变成没人引用的死键,以及每一个 `var(--dsw-…)` 都必须是已安装主题确实发布过的 token —— 未定义的自定义属性会让声明被丢掉、属性悄悄回落到继承值,placeholder 因此曾经画成正文字的墨色(读不到主题时这一节跳过并说明)。
254
- - **`check:node`** — 把构建好的 node 半边通过它真实的 RPC 处理器跑一遍,`DSH_HOME` 指向临时目录,并把 `globalThis.fetch` 换成桩 —— 节日画现在住在 CDN 上,这套校验不能依赖某个 CDN 是活的,也不该去断言一个不属于自己的文件今天有多少字节。它覆盖:节日配置块穿过共享的 sanitizer;仅仅加载插件或读取配置不会发起任何下载;画是按需下载并缓存到 `holiday-cache/`、按**素材名**而不是槽位命名;第二次读取直接读盘、不再传输;两次并发的冷读取共用一次传输;第一个镜像不通时回落到第二个,且两个镜像钉在同一个 commit 上;所有镜像都不通时答案是「没有字节」而不是抛异常,失败的抓取什么都不缓存;镜像返回 200 但内容不是图片时被拒绝而不是被缓存;损坏的缓存条目被丢弃并重新下载;节日槽位拒绝写入 / 删除 / 抓取而丢进去的文件被忽略、且数据目录里永远不会出现 `modelbg-h-*` 文件;普通规则槽位照常写入和删除;手改过的配置无法把节日指向别的规则的图片。它还覆盖逐图主题色这套形状:读取时抬升的两个方向(缺键继承规则色、显式 `null` 不继承)、颜色经过文件往返后各自独立,以及 `null` 是写出键而不是省略。切换效果同样在覆盖范围内:旧配置拿到的出厂默认、选定的效果 / 缓动 / 统一时长能原样穿过文件往返、非法值回退而夸张时长被夹紧、时长为 `0` 被保留而不是回退成默认,以及 `transition` 内部的字段漂移会被上报而不是悄悄丢掉。推荐配置从 node 半边这一侧被整条覆盖:manifest 与 config 按序下载并当场穿过 sanitizer、请求清单由规则推导且把节日槽位剔掉、一张壁纸一个请求且类型是嗅出来的而不是信 header、jsDelivr 对单个文件死了会回落到 raw、两个镜像同时抖一下时同一个文件会被**再问一遍**并最终拿到(三个请求,日志里看得见)、托管处没有的文件仍然只花两次请求(每镜像一次、不等待)、永久 500 的镜像总共只花六次请求(三轮 × 两个镜像 —— 重试是有预算的),以及它每一种失败都是返回值而不是抛异常 —— 所有镜像都不通、镜像回 500、回 200 但内容是 HTML、manifest 的版本不是数字、写给更新形状的配置在请求第一张壁纸之前就被拒绝、config 不是 JSON、config 是 JSON 数组、config 列出的图片数超限、镜像谎报一个超大的长度;再加上严格的那一半(config 点名而托管处没有的壁纸是一个错误,而不是一份短了的配置)、两次在任何请求之前就发生的拒绝(节日槽位、以及一个根本不是槽位的名字),以及新装那条规则:空数据目录读出来正好一条空白规则(`r1`、无匹配串、无颜色、无图),而显式写空的规则列表读回仍然为空 —— 这两者是不同的答案;清空那一步:配置和每一张规则图片都没了、`holiday-cache/` 原封不动、清空后再读拿到的是同一条空白规则而不是报错,以及随后能往空目录里写入这份配置。
254
+ - **`check:node`** — 把构建好的 node 半边通过它真实的 RPC 处理器跑一遍,`DSH_HOME` 指向临时目录,并把 `globalThis.fetch` 换成桩 —— 节日画现在住在 CDN 上,这套校验不能依赖某个 CDN 是活的,也不该去断言一个不属于自己的文件今天有多少字节。它覆盖:节日配置块穿过共享的 sanitizer;仅仅加载插件或读取配置不会发起任何下载;画是按需下载并缓存到 `holiday-cache/<所钉 tag>/` —— 按**发行版**和**素材名**而不是槽位命名,被取代的旧代会在新图落盘之后被清掉;第二次读取直接读盘、不再传输;两次并发的冷读取共用一次传输;第一个镜像不通时回落到第二个,且两个镜像读的是同一个发行 tag;所有镜像都不通时答案是「没有字节」而不是抛异常,失败的抓取什么都不缓存;镜像返回 200 但内容不是图片时被拒绝而不是被缓存;损坏的缓存条目被丢弃并重新下载;节日槽位拒绝写入 / 删除 / 抓取而丢进去的文件被忽略、且数据目录里永远不会出现 `modelbg-h-*` 文件;普通规则槽位照常写入和删除;手改过的配置无法把节日指向别的规则的图片。它还覆盖逐图主题色这套形状:读取时抬升的两个方向(缺键继承规则色、显式 `null` 不继承)、颜色经过文件往返后各自独立,以及 `null` 是写出键而不是省略。切换效果同样在覆盖范围内:旧配置拿到的出厂默认、选定的效果 / 缓动 / 统一时长能原样穿过文件往返、非法值回退而夸张时长被夹紧、时长为 `0` 被保留而不是回退成默认,以及 `transition` 内部的字段漂移会被上报而不是悄悄丢掉。推荐配置从 node 半边这一侧被整条覆盖:manifest 与 config 按序下载并当场穿过 sanitizer、请求清单由规则推导且把节日槽位剔掉、一张壁纸一个请求且类型是嗅出来的而不是信 header、jsDelivr 对单个文件死了会回落到 raw、两个镜像同时抖一下时同一个文件会被**再问一遍**并最终拿到(三个请求,日志里看得见)、托管处没有的文件仍然只花两次请求(每镜像一次、不等待)、永久 500 的镜像总共只花六次请求(三轮 × 两个镜像 —— 重试是有预算的),以及它每一种失败都是返回值而不是抛异常 —— 所有镜像都不通、镜像回 500、回 200 但内容是 HTML、manifest 的版本不是数字、写给更新形状的配置在请求第一张壁纸之前就被拒绝、config 不是 JSON、config 是 JSON 数组、config 列出的图片数超限、镜像谎报一个超大的长度;再加上严格的那一半(config 点名而托管处没有的壁纸是一个错误,而不是一份短了的配置)、两次在任何请求之前就发生的拒绝(节日槽位、以及一个根本不是槽位的名字),以及新装那条规则:空数据目录读出来正好一条空白规则(`r1`、无匹配串、无颜色、无图),而显式写空的规则列表读回仍然为空 —— 这两者是不同的答案;清空那一步:配置和每一张规则图片都没了、`holiday-cache/` 原封不动、清空后再读拿到的是同一条空白规则而不是报错,以及随后能往空目录里写入这份配置。
255
255
 
256
256
  ## 常见问题
257
257
 
@@ -302,19 +302,20 @@ pnpm run typecheck
302
302
 
303
303
  ## 近期优化
304
304
 
305
- > 这里只列最近两个版本;更早的版本(v0.7.6 及以前)见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
305
+ > 这里只列最近两个版本;更早的版本(v0.7.8 及以前)见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
306
306
 
307
- ### v0.7.8
307
+ ### v0.7.10
308
308
 
309
- - **新装打开的就是一条空白规则,而不是「还没有规则」那一屏** —— 此前完全没有配置文件时读出来的是一份空的规则列表,于是第一次装上看到的是那句「还没有规则,点上面的『新增规则』新建一条」:界面在教用户去点一个它还没给出的控件。现在「`rules` 这个键根本不在」(从未配置过)和「键在、数组是空的」(用户把规则删光了)是两个不同的答案,只有前者会种出一条空白规则(id 是 `r1`,所以它就是规则 1、就是兜底、就是面板默认选中的那张卡),后者保持为空 —— 删掉最后一条规则不会被下一次读盘复活。这条规则刻意是「空」而不是「半成品」:它没有图、也没有自己的主题色,所以在被填上东西之前匹配会跳过它,而不是让一个空背景顶掉规则 1 的兜底。
310
- - **placeholder 不再看起来像真的文字** —— 那条 `::placeholder` 颜色写的 `--dsw-alias-label-quaternary` 在这个 harness 的主题里**一个定义都没有**:未定义的自定义属性会让声明在计算值阶段被丢掉,而 `color` 是继承属性,于是 placeholder 拿到输入框自己的 `label-primary` —— 面板里最亮的墨色,写在一个用户一个字都没打过的框里。三个带 placeholder 的输入(规则匹配串、图片网址、轮换的自定义秒数)现在统一读 `--dsw-alias-label-caption`,也就是插件早就给输入框用的那个弱一档的 token;同一个死名字还有两处跟着修掉(`.dab-pick-n` 的 `--dsw-mono` 少了个字母,插件自己的变量是 `--dab-mono`;`.dab-brand-tag` 的那个本来就带 fallback,去掉它不改变颜色)。
311
- - **推荐配置的下载会按文件重试,失败的那一次把已下到的部分留给下一次** —— 此前一个文件只有两次机会(两个镜像各一次),前面几张都下好了、下一张碰上一次 502 或者一次 CDN 抽风,整份配置就失败,而再按一次按钮是从零开始。现在每个文件最多三轮、轮间 400 ms 再 800 ms;三种失败不重试 ——「这个文件不在那儿」(再问也是同一个答案)、「字节本身就超限」(下一轮还是这些字节)、「六十秒都没吭声」(两个镜像在同一轮里都已经问过了,而这一条正是把最坏情况钉在三分钟而不是六分钟的东西),其余失败会再问一轮。失败的那一次把已经拿到的字节留在手里,下一次只补缺的那几张 —— 前提是重新取到的配置与那些字节来自的那一份**逐值相同**(这份配置服务在分支上,两次不同的推送不许拼在一起)。确认框、按钮文案、进度读数的位置一处没动;**严格性也没变**:第一个拿不到的文件仍然让整次失败、仍然报出是哪一个。
309
+ - **节日画的缓存现在按所钉的发行版做键,于是更新过的插件会重新下载,而不是被告知旧的字节就是答案** —— 缓存此前住在 `holiday-cache/<asset>`,这让「文件的身份」和「它背后的字节」脱了钩:把插件指向 assets 仓库的一个新发行版,`readHolidayAsset` 依然能在磁盘上找到 `mid-autumn.webp`,把它返回,然后什么都不说。这正是「钉死引用」本身要防的那种静默失效,只是低了一层 —— 而且它是构造上不可见的,发生在没人盯着的那一天。现在缓存是 `holiday-cache/<tag>/<asset>`(`holiday-cache/v0.1.0/mid-autumn.webp`),tag 进了路径,于是一个钉了新 tag 的构建从一个没人写过的目录开始。`ls holiday-cache/` 现在能回答「这张盘上留着哪几代」。
310
+ - **被取代的那一代会被清掉,而且是在新图落盘之后、绝不在之前。** 带版本的键本来会让每一代都永远留在那里(每代约 730 KB),包括后续发行版已经不再发布的素材。清理发生在一次成功的缓存写入的末尾 —— 那时字节已经在手里、也已经 rename 到位 —— 所以一个取不到的发行版不会让用户丢掉他已经有的画。它删的是**目录**条目、而且只删名字不是本构建 tag 的那些,所以正在用的那一代无论比较结果如何都不可能被碰到。
311
+ - **`check:node` 把这两半都钉住了。** 测试脚本里的 `REF` 现在是一个值,两条 URL 断言**和**缓存路径共用它;另外新增三条断言:所钉那一代里缺失的素材会被重新下载而不是读到旧的、种下的 `v0.0.9` 目录在新图落盘后消失、以及正在用的那一代原封不动。
312
312
 
313
- ### v0.7.7
313
+ ### v0.7.9
314
314
 
315
- - **新装出来就是作者本人那套外观,而且不用联网** —— 「界面」页出厂那套数字现在就是[推荐配置](#推荐配置)里那套:主背景 `0%`、左侧栏 `10%`、右侧栏 `45%`、代码块 `81%`、输入框与控件 `88%` 外加 `60 px` 模糊、对话文本框 `35%`、轨迹页 `73%`。所以一个字节都还没取,界面就已经是对的,而推荐配置那一下点击加进来的是图片,不是把底下的观感换掉。这些数字此前**每一个都是按「安全」而不是按「好看」逐项挑的** —— `bg` 取 0.85 是为了让宿主表面在壁纸下仍然可见,`code` 全不透明是因为「代码块是拿来读的、不是拿来看穿的」,`input` 模糊取 0 是因为一层模糊要付一个合成层的代价 —— 这就是为什么以前新装打开的是一个「宿主的苍白近似」,而不是任何人真会用的配置。预览栏是唯一需要小心处理的字段:`null`(「从没拖过,跟随主背景」)被读回成它自己、而不是当成缺失的键,因为此前每一个版本都**写过**它 —— 用户没拖过的那一栏在磁盘上就是显式的 `null`,把它当成「缺失」会把每一份既有配置里的那一栏都重画一遍。
316
- - **节日开关出厂是关的** —— 第一次装上只画模型规则说的东西;「配置」页那个开关才是「要这个功能」的表达,而推荐配置会把它打开 —— 这就是「选定的配置」和「全新的配置」之间的区别。现在只有真正的布尔值算数:缺失的字段、以及手改出来的非 `true`/`false` 值,都落回出厂默认,而不是旧的「只要不是 `false` 就算开」。
317
- - **「界面」页每张卡片上那个百分比胶囊删掉了** —— 那个数字本来就在它正下方的滑块行上,于是卡头又只剩图标和名字。
315
+ - **所有素材引用都钉到 assets 仓库的同一个发行 tag** —— `v0.1.0`。三处引用此前漂成了三种状态:README 的三张截图读的是 `main` 分支,节日素材钉的是一个原始 commit 哈希(`58a9e107…`,对人来说什么都说明不了),而推荐配置是**刻意**跟着 `main` 的 —— 于是一个插件可能在读一份它构建之后才变过的字节。现在三处都是 `…/assets@v0.1.0/…`,插件所取的每一个字节(截图、节日素材、推荐配置)都来自 assets 仓库的同一个版本,而且这个值人能读、能拿去和 GitHub 上的 tag 对照、也能在一处改完。
316
+ - **节日素材的字节并没有跟着变。** `58a9e107` 是 `v0.1.0` 的祖先,两张 `holiday/*.webp` 在两者之间逐字节相同,所以下载行为不变;变的只是这个引用现在和仓库本身用同一种方式命名。raw 镜像也带 tag(`raw.githubusercontent.com/HarmlessFunny/assets/v0.1.0/…`),所以回落仍然不可能混版本。
317
+ - **推荐配置是刻意放弃「不发版就能修」的** —— 它此前跟着分支,是为了让一个不好的推荐能在 assets 仓库里直接修正、不必等插件发版,理由是推荐配置的失败是**响亮的**(你正盯着那个按钮),而节日的失败是静默的,所以不可变在这里什么都买不到。它买来的代价是:插件的字节可能在装上之后、你点下去之前发生变化;现在一个 tag 同时覆盖截图、节日素材和这份配置,三者都不再有这个问题。改一个 JSON 现在要花一个 `assets` 里的 tag、`src/index.ts` 里的一个值和一次 npm 发版。也要注意 tag **不是**什么:它可以被重新指向,而 jsDelivr 把版本引用按不可变处理,所以重新指向要发一次 purge 请求而不是 push —— 这也是 `presetKey` 在这次改动后仍然保留的原因:一个没清过缓存的边缘节点仍会回同一个 URL 的上一次字节,恢复下载的那一次不能把两个修订拼在一起。
318
+ - **`check:node` 在两个功能上都钉住了这个 tag。** 它的 `revision()` 辅助函数此前找的是 40 位 SHA,根本看不见 tag;现在它从仓库名旁边取出引用(jsDelivr 形状是 `HarmlessFunny/assets@<ref>`,raw 形状是 `HarmlessFunny/assets/<ref>/`),于是两个镜像仍然可以互相比较;两条断言里原来的「a commit, never a branch」和「follows the branch」现在都要求 `v0.1.0`。
318
319
 
319
320
  ## Star History
320
321
 
package/lib/client.js CHANGED
@@ -1455,13 +1455,16 @@ window.__ModuleLoader__.load({
1455
1455
  * The identity of one revision of the profile: the manifest's shape version plus
1456
1456
  * the config, in canonical form.
1457
1457
  *
1458
- * The profile is served from a branch, so "the profile" is not a fixed thing: the
1459
- * bytes a previous attempt is holding are only known to belong to what is being
1460
- * installed when that config comes back the same. Order-insensitivity is what lets
1461
- * this be a comparison of VALUES rather than of spellings — `normalizeConfig`
1462
- * (./schema) rebuilds the config it reads, so pinning the key to the exact key
1463
- * order the wire happened to use would make the hold useless the moment anything
1464
- * upstream normalized differently.
1458
+ * The profile is served at ONE version of the assets repository, but a version
1459
+ * reference is only as fixed as the tag behind it: re-point the tag and every
1460
+ * edge that has not been purged keeps answering with the previous bytes of the
1461
+ * same URL. So "the profile" is still not guaranteed to be one fixed thing, and
1462
+ * the bytes a previous attempt is holding are only known to belong to what is
1463
+ * being installed when that config comes back the same. Order-insensitivity is
1464
+ * what lets this be a comparison of VALUES rather than of spellings —
1465
+ * `normalizeConfig` (./schema) rebuilds the config it reads, so pinning the key
1466
+ * to the exact key order the wire happened to use would make the hold useless
1467
+ * the moment anything upstream normalized differently.
1465
1468
  */
1466
1469
  function presetKey(version, config) {
1467
1470
  return `${version}\n${canonical(config)}`;
@@ -8249,8 +8252,9 @@ window.__ModuleLoader__.load({
8249
8252
  * next press (`presetHeld` below), which is the difference between retrying
8250
8253
  * one file and retrying the lot. Nothing has been touched at this point, so
8251
8254
  * "the author renamed a file" arrives as an error naming the file rather
8252
- * than as a profile that is quietly missing a picture — and that loudness is
8253
- * the whole reason this feature is allowed to follow a branch.
8255
+ * than as a profile that is quietly missing a picture — and that loudness
8256
+ * is what makes a reference the plugin does not fully control acceptable
8257
+ * here.
8254
8258
  */
8255
8259
  fetchRecommended: async (onProgress) => {
8256
8260
  const head = await fetchPresetConfig();
package/lib/index.js CHANGED
@@ -569,8 +569,9 @@ function presetRetryDelayMs(failedAttempt) {
569
569
  * theme-config.json the ordered rule list + the global interface settings
570
570
  * modelbg-<slot> one background image per rule (raw bytes, no extension;
571
571
  * the MIME is sniffed from the magic bytes when served)
572
- * holiday-cache/ festival art downloaded from the CDN, one file per
573
- * holiday asset — deliberately NOT a slot (see below)
572
+ * holiday-cache/<tag>/ festival art downloaded from the CDN — one directory
573
+ * per pinned release of the asset repository, one file per
574
+ * holiday asset inside it, deliberately NOT a slot (below)
574
575
  *
575
576
  * Images travel as data URLs over the RPC channel — there is no separate HTTP
576
577
  * image route. A legacy single-wallpaper / video-background store is migrated
@@ -603,32 +604,49 @@ const WALLPAPER_FETCH_TIMEOUT = 2e4;
603
604
  * the `assets` repository beside the README screenshots and are fetched from a
604
605
  * CDN the first time a holiday actually needs one (see `readHolidayAsset`).
605
606
  *
606
- * The reference is a **commit**, never a branch. Runtime art has to be
607
+ * The reference is a **release tag**, never a branch. Runtime art has to be
607
608
  * immutable: a branch reference would let a later reshuffle of the documentation
608
609
  * screenshots silently kill a shipped feature, on a day nobody is watching, with
609
- * nothing in the log — and jsDelivr caches a commit reference forever, which is
610
- * exactly what a file that never changes wants. The cost is that new art means a
611
- * new commit and a new hash here.
610
+ * nothing in the log — and jsDelivr serves a version reference as immutable,
611
+ * which is exactly what a file that never changes wants. A tag says that to the
612
+ * CDN in the same breath as it says it to a human: `v0.1.0` is the reference the
613
+ * README's screenshots and the recommended profile are read at, so one release
614
+ * of the assets repository is one version of every byte this plugin fetches,
615
+ * where a raw commit hash was a value nobody could compare against anything.
616
+ * The cost is unchanged — new art means a new tag there and a new value here —
617
+ * with one addition: a tag that is MOVED does not reach jsDelivr's edge on its
618
+ * own (the reference is served as immutable), so re-pointing one means a purge
619
+ * request. Cutting a new tag is the cheap move.
612
620
  */
613
621
  const HOLIDAY_ASSETS_REPO = "HarmlessFunny/assets";
614
- const HOLIDAY_ASSETS_COMMIT = "58a9e107ac9e1993035c494397dfe77bbb7e8720";
622
+ const HOLIDAY_ASSETS_REF = "v0.1.0";
615
623
  const HOLIDAY_ASSETS_DIR = "dsh-background-by-model/holiday";
616
624
  /**
617
625
  * Mirrors, tried in order. jsDelivr first because it is reachable from networks
618
626
  * where `raw.githubusercontent.com` is not; raw second because a CDN can be
619
627
  * blocked, rate-limited or down, and one dead mirror must not cost the easter
620
- * egg. Every mirror serves the same commit, so a fallback cannot mix revisions.
628
+ * egg. Every mirror serves the same version, so a fallback cannot mix revisions.
621
629
  */
622
- const HOLIDAY_ASSET_HOSTS = [`https://cdn.jsdelivr.net/gh/${HOLIDAY_ASSETS_REPO}@${HOLIDAY_ASSETS_COMMIT}/${HOLIDAY_ASSETS_DIR}`, `https://raw.githubusercontent.com/${HOLIDAY_ASSETS_REPO}/${HOLIDAY_ASSETS_COMMIT}/${HOLIDAY_ASSETS_DIR}`];
630
+ 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}`];
623
631
  /**
624
- * Sub-directory of the store holding downloaded festival art.
632
+ * Sub-directory of the store holding downloaded festival art: one directory per
633
+ * release of the asset repository, one file per asset inside it.
625
634
  *
626
635
  * It is NOT part of the slot namespace and must never become part of it: the
627
- * cached files are named after the ASSET (`mid-autumn.webp`), not after a slot,
628
- * so nothing here can ever be mistaken for a user's `modelbg-<slot>`. That is
629
- * what lets a holiday slot stay read-only — `writeImage` still refuses it, and
630
- * the store still never holds a `modelbg-h-*` file — while the art itself is
631
- * now fetched rather than shipped.
636
+ * cached files are named after the ASSET (`mid-autumn.webp`) and their parent
637
+ * after the release the plugin pins (`v0.1.0`), never after a slot, so nothing
638
+ * here can ever be mistaken for a user's `modelbg-<slot>`. That is what lets a
639
+ * holiday slot stay read-only — `writeImage` still refuses it, and the store
640
+ * still never holds a `modelbg-h-*` file — while the art itself is now fetched
641
+ * rather than shipped.
642
+ *
643
+ * The version segment is load-bearing. A cache keyed by asset name alone answers
644
+ * a NEW generation with the OLD bytes and says nothing about it, on a day nobody
645
+ * is watching — which is the failure the pinned reference exists to prevent, one
646
+ * layer further down. With the tag in the path, a build that pins a new tag looks
647
+ * in a directory nobody has written to yet, so the first holiday after an update
648
+ * is a cache MISS instead of a stale hit. What happens to the generation that
649
+ * leaves behind is `pruneHolidayCache`.
632
650
  */
633
651
  const HOLIDAY_CACHE_DIR = "holiday-cache";
634
652
  /** Cap and timeout for one asset download; these files are 228 KB and 500 KB. */
@@ -652,22 +670,35 @@ const HOLIDAY_FETCH_TIMEOUT = 1e4;
652
670
  * `rules[].images[].slot`, so adding a wallpaper is adding a rule entry and a
653
671
  * file, with no second place to keep in sync.
654
672
  *
655
- * Referenced by BRANCH, unlike the festival art, and deliberately so. The reason
656
- * that art is pinned to a commit does not apply here: its rationale is that a
657
- * reshuffle of the assets repository would make a shipped feature disappear
658
- * SILENTLY, on a day nobody is watching. A profile fails LOUDLY — the user
659
- * clicks a button and gets an error naming what went missing — so the failure
660
- * immutability buys protection from cannot happen. What branch referencing buys
661
- * instead is the ability to fix a recommendation without a plugin release:
662
- * pinning would mean editing one JSON file costs a new commit, a new hash in
663
- * this file and a new version on npm, and every user on an older build would
664
- * keep getting the old recommendation forever.
673
+ * Referenced by the same **release tag** as the festival art. This used to be a
674
+ * branch, and deliberately: the reason the art is immutable — a reshuffle of the
675
+ * assets repository would make a shipped feature disappear SILENTLY, on a day
676
+ * nobody is watching — does not apply to a profile, because a profile fails
677
+ * LOUDLY (the user is looking at the button and gets an error naming what went
678
+ * missing), and a branch bought the ability to correct a bad recommendation
679
+ * without a plugin release.
680
+ *
681
+ * That trade is now the other way round. What a branch also bought was a plugin
682
+ * whose bytes could change under the user between the build and the click, and
683
+ * with one tag covering the screenshots, the festival art and this profile, one
684
+ * release of the assets repository is one version of everything the plugin
685
+ * fetches — the same value in the README, in `HOLIDAY_ASSETS_REF` and here. The
686
+ * price is that correcting one JSON file now costs a tag in `assets`, a new
687
+ * value in this file and a release on npm, and that MOVING the tag does not
688
+ * reach jsDelivr's edge by itself (it is served as immutable, and the purge tool
689
+ * is the only way to re-point it) — so a correction is a new tag, never a
690
+ * force-push of the old one.
691
+ *
692
+ * The re-pointed-tag case is why `presetKey` survives this change: an edge that
693
+ * has not been purged can still answer with the previous bytes of the same URL,
694
+ * so two fetches of "the profile" are not guaranteed to be one revision, and a
695
+ * press that resumes must not glue them together. See `fetchPresetConfig`.
665
696
  *
666
697
  * The risk that IS real — a profile written for a config shape this build cannot
667
698
  * read — is what `preset.json` exists for (see `fetchPresetConfig`).
668
699
  */
669
700
  const PRESET_ASSETS_REPO = "HarmlessFunny/assets";
670
- const PRESET_ASSETS_REF = "main";
701
+ const PRESET_ASSETS_REF = "v0.1.0";
671
702
  const PRESET_ASSET_DIR = "dsh-background-by-model/preset";
672
703
  /** The shape gate: `{"version": 6}` — see SCHEMA_VERSION in ./schema. */
673
704
  const PRESET_MANIFEST = "preset.json";
@@ -709,7 +740,7 @@ const dataDir = () => dshHomePath(DATA_DIR);
709
740
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
710
741
  const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
711
742
  const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
712
- const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, asset);
743
+ const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF, asset);
713
744
  const exists = async (p) => {
714
745
  try {
715
746
  await access(p);
@@ -1203,16 +1234,48 @@ async function fetchPresetImage(slot) {
1203
1234
  async function writeHolidayCache(asset, buf) {
1204
1235
  try {
1205
1236
  await ensureDir();
1206
- await mkdir(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR), { recursive: true });
1237
+ await mkdir(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF), { recursive: true });
1207
1238
  const target = holidayCachePath(asset);
1208
1239
  const temp = `${target}.tmp`;
1209
1240
  await writeFile(temp, buf);
1210
1241
  await rename(temp, target);
1242
+ await pruneHolidayCache();
1211
1243
  } catch (e) {
1212
1244
  console.warn(`dsh-background-by-model: could not cache the "${asset}" holiday art`, e);
1213
1245
  }
1214
1246
  }
1215
1247
  /**
1248
+ * Drop every generation of the cache except the one this build reads.
1249
+ *
1250
+ * Called AFTER a successful download, never before it, so a generation is only
1251
+ * ever removed once its replacement is on disk: a pinned release that turns out
1252
+ * to be unreachable must not cost the user the art they already had.
1253
+ *
1254
+ * It walks the cache ROOT and removes every entry whose name is not this build's
1255
+ * tag, so the generation in use cannot be touched however the comparison goes:
1256
+ * the entries it compares are generation names, never asset names. That also
1257
+ * makes an upgrade clean up after itself — 0.7.9 cached flat `holiday-cache/<asset>`
1258
+ * files, which this build never reads and this sweep removes.
1259
+ *
1260
+ * Best-effort, like the write that calls it: a cache that could not be pruned is
1261
+ * a cache with one extra entry in it, and failing a festival over that would be
1262
+ * the tail wagging the dog.
1263
+ */
1264
+ async function pruneHolidayCache() {
1265
+ try {
1266
+ const root = dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR);
1267
+ for (const entry of await readdir(root)) {
1268
+ if (entry === HOLIDAY_ASSETS_REF) continue;
1269
+ await rm(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, entry), {
1270
+ recursive: true,
1271
+ force: true
1272
+ });
1273
+ }
1274
+ } catch (e) {
1275
+ console.warn("dsh-background-by-model: could not prune the older holiday art", e);
1276
+ }
1277
+ }
1278
+ /**
1216
1279
  * The wallpaper a holiday slot resolves to, or null when the slot is not a
1217
1280
  * holiday's.
1218
1281
  *
@@ -1261,10 +1324,11 @@ async function listSlots() {
1261
1324
  * makes the profile's own slots free to write, and it is why this feature needs
1262
1325
  * no slot remapping: there is nothing left to collide with.
1263
1326
  *
1264
- * `holiday-cache/` is deliberately untouched. It is not the user's
1265
- * configuration: it is downloaded festival art that belongs to no slot, costs a
1266
- * network round trip to get back, and has nothing to do with which profile is
1267
- * selected.
1327
+ * `holiday-cache/` is deliberately untouched — every generation of it. It is not
1328
+ * the user's configuration: it is downloaded festival art that belongs to no
1329
+ * slot, costs a network round trip to get back, and has nothing to do with which
1330
+ * profile is selected. Pruning superseded generations is `pruneHolidayCache`'s
1331
+ * job, and it is not this one.
1268
1332
  *
1269
1333
  * Best-effort per entry — one unremovable file must not abort the rest and leave
1270
1334
  * the store half wiped — but the failures are logged rather than swallowed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-background-by-model",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
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 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",