dsh-background-by-model 0.9.2 → 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/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
  /**
@@ -108,6 +124,28 @@ const DEFAULT_TRANSITION = {
108
124
  easing: "ease",
109
125
  durationMs: 320
110
126
  };
127
+ /**
128
+ * One image entry for a place that CREATES a picture rather than reads one back
129
+ * (a fresh holiday, and every upload in the panel).
130
+ *
131
+ * The two effects whose neutral value is also the shipped default are written
132
+ * explicitly (`opacity`, `blur`), even though `normalizeImage` would default them:
133
+ * the read-time lifts key off the ABSENCE of a key, so an entry created without one
134
+ * would come back as "written before this field existed" and inherit whatever the
135
+ * rule happened to carry. `brightness` is the one field whose absence is meaningful
136
+ * (see `BgImage.brightness`), so it is left out — "untouched" is exactly what a new
137
+ * picture is.
138
+ */
139
+ function defaultImage(slot, color = null, bgMode = "fit") {
140
+ return {
141
+ slot,
142
+ bgMode,
143
+ bgState: { ...DEFAULT_BG_STATE },
144
+ color,
145
+ opacity: 1,
146
+ blur: 0
147
+ };
148
+ }
111
149
  const DEFAULT_CHAT_TEXT_OPACITY = .35;
112
150
  const DEFAULT_TRAJECTORY_OPACITY = .73;
113
151
  const DEFAULT_RIGHTBAR_OPACITY = .45;
@@ -156,18 +194,10 @@ function defaultHolidayRule(def) {
156
194
  const color = hexToHsl(def.color);
157
195
  return {
158
196
  id: def.id,
159
- images: [{
160
- slot: def.slot,
161
- bgMode: "fill",
162
- bgState: { ...DEFAULT_BG_STATE },
163
- color
164
- }],
197
+ images: [defaultImage(def.slot, color, "fill")],
165
198
  match: "",
166
199
  enabled: true,
167
200
  color,
168
- wallpaperOpacity: 1,
169
- brightness: 100,
170
- blur: 0,
171
201
  rotate: defaultRotation()
172
202
  };
173
203
  }
@@ -187,9 +217,6 @@ function blankRule(id) {
187
217
  match: "",
188
218
  enabled: true,
189
219
  color: null,
190
- wallpaperOpacity: 1,
191
- brightness: 100,
192
- blur: 0,
193
220
  rotate: defaultRotation()
194
221
  };
195
222
  }
@@ -276,28 +303,41 @@ function normalizeTransition(raw, legacyMs) {
276
303
  durationMs: clamp(t.durationMs, 0, TRANSITION_MAX_MS, fallback)
277
304
  };
278
305
  }
306
+ /** The inherit block for a caller that has no rule-level values at all. */
307
+ const DEFAULT_IMAGE_INHERIT = {
308
+ color: null,
309
+ opacity: 1,
310
+ brightness: void 0,
311
+ blur: 0
312
+ };
279
313
  /**
280
314
  * Coerce one persisted image entry, or null when it lacks a usable slot.
281
315
  *
282
- * `inherit` (the RULE's colour) is applied only to an entry with no `color` KEY at
283
- * all — the shape written before per-image colours, where the rule's colour was the
284
- * only one there was. The key's PRESENCE is the whole distinction: `color: null` is
285
- * a deliberate "follow the system theme" and must not be re-inherited on every load.
316
+ * `inherit` is the rule-level block this entry may take its values from, and each
317
+ * field is lifted ONLY when the entry has no key of its own — that absence is the
318
+ * whole signal. `color: null` is a deliberate "follow the system theme" and must
319
+ * not be re-inherited on every load; `brightness` keeps its key absent when neither
320
+ * side ever named one, so an upgrade never writes a default nobody chose.
286
321
  *
287
322
  * `inheritMode` is the same one-shot lift for the layout mode. It is the caller's to
288
323
  * resolve because the callers answer differently: a user rule passes the mode its
289
324
  * config carried at the RULE level (schema 5), a holiday passes its own `fill`. An
290
325
  * entry that HAS a `bgMode` key keeps it, even when it equals the default.
291
326
  */
292
- function normalizeImage(raw, inherit = null, inheritMode = "fit") {
327
+ function normalizeImage(raw, inherit = DEFAULT_IMAGE_INHERIT, inheritMode = "fit") {
293
328
  const i = raw ?? {};
294
329
  if (typeof i.slot !== "string" || !SLOT_RE.test(i.slot)) return null;
295
- return {
330
+ const image = {
296
331
  slot: i.slot,
297
332
  bgMode: BG_MODES.includes(i.bgMode) ? i.bgMode : inheritMode,
298
333
  bgState: normalizeBgState(i.bgState),
299
- color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit
334
+ color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit.color,
335
+ opacity: clamp01(i.opacity, inherit.opacity),
336
+ blur: clamp(i.blur, 0, 60, inherit.blur)
300
337
  };
338
+ const brightness = i.brightness === void 0 ? inherit.brightness : clamp(i.brightness, 0, 200, 100);
339
+ if (brightness !== void 0) image.brightness = brightness;
340
+ return image;
301
341
  }
302
342
  /**
303
343
  * Coerce one persisted rule, or null when it names no image ANYWHERE.
@@ -322,38 +362,45 @@ function normalizeRule(raw) {
322
362
  if (id === null) return null;
323
363
  const color = normalizeHsl(r.color);
324
364
  const mode = BG_MODES.includes(r.bgMode) ? r.bgMode : "fit";
365
+ const inherit = {
366
+ color,
367
+ opacity: clamp01(r.wallpaperOpacity, 1),
368
+ brightness: r.brightness === void 0 ? void 0 : clamp(r.brightness, 0, 200, 100),
369
+ blur: clamp(r.blur, 0, 60, 0)
370
+ };
325
371
  const images = [];
326
372
  const seen = /* @__PURE__ */ new Set();
327
373
  const hadList = Array.isArray(r.images);
328
374
  if (hadList) for (const entry of r.images) {
329
- const image = normalizeImage(entry, color, mode);
375
+ const image = normalizeImage(entry, inherit, mode);
330
376
  if (image === null || seen.has(image.slot)) continue;
331
377
  seen.add(image.slot);
332
378
  images.push(image);
333
379
  }
334
380
  const legacySlot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
335
- if (images.length === 0 && legacySlot !== null) images.push({
336
- slot: legacySlot,
337
- bgMode: mode,
338
- bgState: normalizeBgState(r.bgState),
339
- color
340
- });
381
+ if (images.length === 0 && legacySlot !== null) {
382
+ const image = {
383
+ slot: legacySlot,
384
+ bgMode: mode,
385
+ bgState: normalizeBgState(r.bgState),
386
+ color,
387
+ opacity: inherit.opacity,
388
+ blur: inherit.blur
389
+ };
390
+ if (inherit.brightness !== void 0) image.brightness = inherit.brightness;
391
+ images.push(image);
392
+ }
341
393
  if (images.length === 0 && !hadList) return null;
342
394
  const rotate = normalizeRotation(r.rotate);
343
395
  if (images.length < 2) rotate.enabled = false;
344
- const brightness = r.brightness === void 0 ? void 0 : clamp(r.brightness, 0, 200, 100);
345
- const rule = {
396
+ return {
346
397
  id,
347
398
  images,
348
399
  match: typeof r.match === "string" ? r.match : "",
349
400
  enabled: r.enabled !== false,
350
401
  color,
351
- wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
352
- blur: clamp(r.blur, 0, 60, 0),
353
402
  rotate
354
403
  };
355
- if (brightness !== void 0) rule.brightness = brightness;
356
- return rule;
357
404
  }
358
405
  /**
359
406
  * Coerce one persisted holiday entry.
@@ -370,17 +417,29 @@ function normalizeHolidayRule(def, raw) {
370
417
  return {
371
418
  ...base,
372
419
  enabled: r.enabled !== false,
373
- wallpaperOpacity: clamp01(r.wallpaperOpacity, base.wallpaperOpacity),
374
- brightness: clamp(r.brightness, 0, 200, base.brightness ?? 100),
375
- blur: clamp(r.blur, 0, 60, base.blur),
376
- images: base.images.map((image) => ({
377
- slot: image.slot,
378
- bgMode: image.bgMode,
379
- bgState: normalizeBgState(r.images?.[0]?.bgState ?? r.bgState),
380
- color: image.color
381
- }))
420
+ images: base.images.map((image) => {
421
+ const stored = r.images?.[0];
422
+ const merged = {
423
+ slot: image.slot,
424
+ bgMode: image.bgMode,
425
+ bgState: normalizeBgState(stored?.bgState ?? r.bgState),
426
+ color: image.color,
427
+ opacity: clamp01(stored?.opacity ?? legacyWallpaperOpacity(r.wallpaperOpacity), image.opacity),
428
+ blur: clamp(stored?.blur ?? r.blur, 0, 60, image.blur)
429
+ };
430
+ const brightness = stored?.brightness === void 0 ? r.brightness === void 0 ? void 0 : clamp(r.brightness, 0, 200, 100) : clamp(stored.brightness, 0, 200, 100);
431
+ if (brightness !== void 0) merged.brightness = brightness;
432
+ return merged;
433
+ })
382
434
  };
383
435
  }
436
+ /**
437
+ * The rule-level opacity a schema-9 holiday entry carried, or `undefined` so the
438
+ * caller can tell "absent" from "set to 1" and leave `clamp01` its default.
439
+ */
440
+ function legacyWallpaperOpacity(raw) {
441
+ return typeof raw === "number" && isFinite(raw) ? raw : void 0;
442
+ }
384
443
  /** Coerce the whole holiday block; unknown ids are dropped, missing ones defaulted. */
385
444
  function normalizeHolidays(raw) {
386
445
  const r = raw ?? {};
@@ -432,20 +491,21 @@ function normalizeConfig(raw) {
432
491
  *
433
492
  * Three reasons are terminal, and each for its own:
434
493
  *
435
- * - `missing` — the host says the file is not there. The profile follows a
436
- * BRANCH (see `PRESET_ASSET_HOSTS` in ./index), so a renamed or deleted file
437
- * has to reach the user as a loud failure NAMING the slot — and it has to
438
- * reach them FAST: paying two more rounds for a file that is provably absent
439
- * 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.
440
500
  * - `oversized` — the bytes are the problem, and the next round would serve the
441
501
  * same bytes.
442
- * - `timeout` — sixty seconds of silence is not weather. Both mirrors were
443
- * already asked inside the SAME round, so a retry round would re-ask two hosts
444
- * that each just spent a minute not answering. This is also what keeps the
445
- * worst case for one file at three minutes instead of six: a round in which BOTH
446
- * mirrors were silent is over immediately (two minutes, exactly what it cost
447
- * before there were rounds at all), and only a round that mixed a silent mirror
448
- * 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.
449
509
  *
450
510
  * Everything else is a hiccup: a thrown connection, a 5xx, a 429, a 408, or a 200
451
511
  * with no body.
@@ -457,7 +517,7 @@ function presetRetryable(reason) {
457
517
  * The delay before the round that follows `failedAttempt` (1-based), in ms.
458
518
  *
459
519
  * Doubling rather than fixed: the second retry is the one that has to survive a
460
- * 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
461
521
  * a user staring at "downloading" does not read it as a hang.
462
522
  */
463
523
  function presetRetryDelayMs(failedAttempt) {
@@ -499,6 +559,88 @@ function orphanSlots(stored, referenced, kept = []) {
499
559
  return stored.filter((slot) => !live.has(slot));
500
560
  }
501
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
502
644
  //#region src/index.ts
503
645
  /**
504
646
  * Node half of dsh-background-by-model: file-backed rule persistence.
@@ -508,8 +650,9 @@ function orphanSlots(stored, referenced, kept = []) {
508
650
  * `/api`, which slash commands own).
509
651
  *
510
652
  * theme-config.json the ordered rule list + the global interface settings
511
- * modelbg-<slot> one background image per rule (raw bytes, no extension;
512
- * the MIME is sniffed from the magic bytes when served)
653
+ * theme-config.json.bak the last config that parsed, rotated in before every save
654
+ * modelbg-<slot> one background image (raw bytes, no extension; the MIME is
655
+ * sniffed from the magic bytes when served)
513
656
  * holiday-cache/<tag>/ festival art downloaded from the CDN, one directory per
514
657
  * pinned release, deliberately NOT in the slot namespace
515
658
  *
@@ -521,6 +664,20 @@ const name = "dsh-background-by-model";
521
664
  const inject = ["connection", "webServer"];
522
665
  const DATA_DIR = ".dsh-background-by-model-data";
523
666
  const CONFIG_FILE = "theme-config.json";
667
+ /**
668
+ * The last config that was known to parse, kept beside the live one.
669
+ *
670
+ * A config file is the whole rule list, and it is rewritten on every edit. Writing
671
+ * it in place meant a crash, a full disk or a pulled power cable could leave half a
672
+ * JSON document behind — which `readConfig` greets by silently falling back to the
673
+ * defaults, so the next slider drag would persist those defaults over the ruined
674
+ * file and the original rules were gone for good. Two pieces fix that, and they are
675
+ * one feature: the write is atomic (temp + rename), and the previous good file is
676
+ * rotated to `.bak` before it is replaced, so even a lost file has a copy.
677
+ */
678
+ const CONFIG_BACKUP = "theme-config.json.bak";
679
+ /** Suffix of the in-flight write. Deliberately outside `listSlots`' `modelbg-` prefix. */
680
+ const CONFIG_TEMP = ".tmp";
524
681
  const IMAGE_PREFIX = "modelbg-";
525
682
  const LEGACY_WALLPAPER = "wallpaper.jpg";
526
683
  const LEGACY_FILES = [
@@ -535,96 +692,70 @@ const LEGACY_FILES = [
535
692
  const WALLPAPER_FETCH_MAX = 26214400;
536
693
  const WALLPAPER_FETCH_TIMEOUT = 2e4;
537
694
  /**
538
- * Where the festival art is hosted, and which revision of it this build wants.
695
+ * Where the festival art is hosted.
539
696
  *
540
- * The art is fetched from the `assets` repository the first time a holiday needs it
541
- * (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).
542
701
  *
543
- * The reference is a **release tag**, never a branch: runtime art has to be
544
- * immutable, and a branch reference would let a reshuffle of the documentation
545
- * screenshots silently kill a shipped feature. One tag also means one version of
546
- * everything the plugin fetches (art, profile, README screenshots). A MOVED tag
547
- * does not reach jsDelivr's edge on its own, so new art means a new tag, never a
548
- * 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.
549
706
  */
550
- const HOLIDAY_ASSETS_REPO = "HarmlessFunny/assets";
551
- const HOLIDAY_ASSETS_REF = "v0.1.0";
552
- const HOLIDAY_ASSETS_DIR = "dsh-background-by-model/holiday";
553
- /**
554
- * Mirrors, tried in order: jsDelivr is reachable where `raw.githubusercontent.com`
555
- * is not, and raw is the fallback for a CDN that is blocked or down. Both serve the
556
- * same version, so a fallback cannot mix revisions.
557
- */
558
- 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
+ ];
559
712
  /**
560
713
  * Sub-directory of the store holding downloaded festival art: one directory per
561
- * release of the asset repository, one file per asset inside it.
714
+ * GENERATION of the art (see `holidayArtGeneration`), one file per asset inside it.
562
715
  *
563
716
  * NOT part of the slot namespace, and must never become part of it: files are named
564
- * after the ASSET and their parent after the pinned release, never after a slot, so
565
- * 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
566
719
  * holiday slot stay read-only while the art itself is downloaded.
567
720
  *
568
- * The version segment is load-bearing: a cache keyed by asset name alone would
569
- * answer a NEW generation with the OLD bytes and say nothing about it. With the tag
570
- * in the path, a build that pins a new tag looks in a directory nobody has written
571
- * to yet, so the first holiday after an update is a miss. Superseded generations
572
- * 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`.
573
726
  */
574
727
  const HOLIDAY_CACHE_DIR = "holiday-cache";
575
- /** 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. */
576
729
  const HOLIDAY_FETCH_MAX = 8388608;
577
730
  const HOLIDAY_FETCH_TIMEOUT = 1e4;
578
731
  /**
579
- * The recommended profile, served from the same assets repository as the festival
580
- * art — as a DIRECTORY in the store's own shape: a readable `theme-config.json`
581
- * plus one raw file per slot. Publishing it is a `cp` out of the data directory,
582
- * not an export step.
583
- *
584
- * Nothing lists the images: the request set is DERIVED from the config's own
585
- * `rules[].images[].slot`, so adding a wallpaper is a rule entry and a file.
586
- *
587
- * Referenced by the same release tag as the art (one tag = one version of
588
- * everything the plugin fetches). The re-pointed-tag case is why `presetKey`
589
- * exists: an edge that has not been purged can still answer with the previous
590
- * bytes of the same URL, and a resumed download must not glue two revisions
591
- * together (see `fetchPresetConfig`). A profile written for a config shape this
592
- * build cannot read is refused by the `preset.json` gate.
593
- */
594
- const PRESET_ASSETS_REPO = "HarmlessFunny/assets";
595
- const PRESET_ASSETS_REF = "v0.1.0";
596
- const PRESET_ASSET_DIR = "dsh-background-by-model/preset";
597
- /** The shape gate: `{"version": 9}` — see SCHEMA_VERSION in ./schema. */
598
- const PRESET_MANIFEST = "preset.json";
599
- const PRESET_CONFIG = "theme-config.json";
600
- /** Images are named after the slot they fill, exactly as they are on disk. */
601
- const PRESET_IMAGE_PREFIX = "modelbg-";
602
- /** Mirrors, tried in order — same reason as the festival art's. */
603
- 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}`];
604
- /**
605
- * Caps for the profile, now per file rather than for one document: a profile is a
606
- * SET of transfers, and each has a different way of going wrong.
607
- *
608
- * The count is the one that matters — the request set is derived from the config,
609
- * so a hostile config could name a thousand slots and turn one button press into a
610
- * thousand downloads. `SLOT_RE` bounds each name, `PRESET_IMAGE_COUNT_MAX` bounds
611
- * how many there are, and the running TOTAL is bounded on the client, which is the
612
- * only side that sees more than one file.
613
- *
614
- * The timeout is set by measurement: a 350 KB wallpaper takes a second or two over
615
- * jsDelivr, and the fallback mirror is the slower one, so a tight budget would fail
616
- * 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.
617
743
  */
618
- const PRESET_MANIFEST_MAX = 65536;
619
- const PRESET_CONFIG_MAX = 1048576;
620
744
  const PRESET_IMAGE_MAX = 8388608;
621
- const PRESET_IMAGE_COUNT_MAX = 64;
622
745
  const PRESET_FETCH_TIMEOUT = 6e4;
623
746
  const dataDir = () => dshHomePath(DATA_DIR);
624
747
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
748
+ const configBackupPath = () => dshHomePath(DATA_DIR, CONFIG_BACKUP);
749
+ const configTempPath = () => dshHomePath(DATA_DIR, `${CONFIG_FILE}${CONFIG_TEMP}`);
625
750
  const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
626
751
  const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
627
- 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
+ };
628
759
  const exists = async (p) => {
629
760
  try {
630
761
  await access(p);
@@ -670,14 +801,15 @@ async function migrateLegacy(raw) {
670
801
  images: [{
671
802
  slot: "m1",
672
803
  bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
673
- bgState: r.bgState ?? {}
804
+ bgState: r.bgState ?? {},
805
+ color: Array.isArray(r.color) ? r.color : null,
806
+ opacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
807
+ ...typeof r.brightness === "number" ? { brightness: r.brightness } : {},
808
+ blur: typeof r.blur === "number" ? r.blur : 0
674
809
  }],
675
810
  match: "",
676
811
  enabled: true,
677
- color: Array.isArray(r.color) ? r.color : null,
678
- wallpaperOpacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
679
- brightness: typeof r.brightness === "number" ? r.brightness : 100,
680
- blur: typeof r.blur === "number" ? r.blur : 0
812
+ color: Array.isArray(r.color) ? r.color : null
681
813
  }],
682
814
  opacities: r.opacities,
683
815
  settingsOpacity: r.settingsOpacity,
@@ -686,7 +818,7 @@ async function migrateLegacy(raw) {
686
818
  rightbarOpacity: r.rightbarOpacity
687
819
  };
688
820
  try {
689
- await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
821
+ await writeConfigFile(serializeConfig(next));
690
822
  } catch (e) {
691
823
  console.warn("dsh-background-by-model: could not rewrite the migrated config", e);
692
824
  }
@@ -699,11 +831,121 @@ async function ensureDir() {
699
831
  console.warn(`dsh-background-by-model: cannot create data dir "${dataDir()}"`, e);
700
832
  }
701
833
  }
834
+ /** The exact bytes a config file should hold. */
835
+ function serializeConfig(config) {
836
+ return JSON.stringify(config, null, 2);
837
+ }
838
+ /**
839
+ * Whether a string is a config this build would accept: JSON, and an object.
840
+ *
841
+ * Deliberately looser than `normalizeConfig` (which accepts anything and defaults
842
+ * every field): this is the test for "is there something worth keeping", so a
843
+ * schema-3 file and a hand-edited one both count. What it rejects is the two ways a
844
+ * write can be interrupted — half a document, and an empty one.
845
+ */
846
+ function parsesAsConfig(text) {
847
+ try {
848
+ const value = JSON.parse(text);
849
+ return value !== null && typeof value === "object" && !Array.isArray(value);
850
+ } catch {
851
+ return false;
852
+ }
853
+ }
854
+ /**
855
+ * Write one config file so that no reader can ever see a partial document, keeping
856
+ * the previous good one as `.bak`.
857
+ *
858
+ * Two steps, in this order, because the order is what makes the copy worth having:
859
+ *
860
+ * 1. the file on disk NOW is copied to `theme-config.json.bak` — but only when it
861
+ * parses. A corrupted live file must never overwrite the last good backup, or
862
+ * the first write after the corruption would also destroy the copy that could
863
+ * have replaced it;
864
+ * 2. the new bytes go to a temp name and are RENAMED over the live path. A rename
865
+ * is atomic within one directory, so a crash either leaves the old file or the
866
+ * new one — never half of either.
867
+ *
868
+ * The rename is not `writeFile` for the same reason: writing in place truncates
869
+ * first, and a crash between the truncation and the last byte is exactly the state
870
+ * that used to eat a rule list.
871
+ */
872
+ async function writeConfigFile(text) {
873
+ await ensureDir();
874
+ const target = configPath();
875
+ try {
876
+ const current = await readFile(target, "utf8");
877
+ if (parsesAsConfig(current)) {
878
+ const backupTemp = `${configBackupPath()}${CONFIG_TEMP}`;
879
+ await writeFile(backupTemp, current, "utf8");
880
+ await renameOver(backupTemp, configBackupPath());
881
+ }
882
+ } catch {}
883
+ const temp = configTempPath();
884
+ await writeFile(temp, text, "utf8");
885
+ await renameOver(temp, target);
886
+ }
887
+ /**
888
+ * `rename` over an existing file, which replaces it atomically on POSIX.
889
+ *
890
+ * Windows refuses to rename onto a name that exists (EEXIST / EPERM), so the one
891
+ * platform that cannot do this in one step gets a two-step fallback — and the
892
+ * difference matters: unlinking first leaves a window with NO file, which is the
893
+ * state this whole function exists to avoid, so on POSIX the unlink must never be
894
+ * attempted. The fallback is only reached when the rename itself said the target is
895
+ * in the way, which is exactly the Windows case.
896
+ */
897
+ async function renameOver(from, to) {
898
+ try {
899
+ await rename(from, to);
900
+ } catch (e) {
901
+ const code = e?.code;
902
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY") throw e;
903
+ await rm(to, { force: true });
904
+ await rename(from, to);
905
+ }
906
+ }
907
+ /**
908
+ * The last good config that can be restored, as raw text — or null when there is
909
+ * none.
910
+ *
911
+ * Two candidates, in order of authority: the live file (when it parses, which is
912
+ * the ordinary case and costs one read) and the `.bak`. The backup is only
913
+ * consulted for a live file that is missing or corrupt, so a half-written document
914
+ * from an interrupted save is answered with the copy rather than with the shipped
915
+ * defaults — which is the difference between "your rules are back" and "your rules
916
+ * were never there".
917
+ */
918
+ async function readConfigFile() {
919
+ try {
920
+ const text = await readFile(configPath(), "utf8");
921
+ if (parsesAsConfig(text)) return {
922
+ text,
923
+ source: "live"
924
+ };
925
+ } catch {}
926
+ try {
927
+ const text = await readFile(configBackupPath(), "utf8");
928
+ if (!parsesAsConfig(text)) return null;
929
+ try {
930
+ await writeConfigFile(text);
931
+ } catch (e) {
932
+ console.warn("dsh-background-by-model: could not restore the config from its backup", e);
933
+ }
934
+ console.warn("dsh-background-by-model: \"theme-config.json\" was missing or unreadable; restored the last saved copy from \"theme-config.json.bak\"");
935
+ return {
936
+ text,
937
+ source: "backup"
938
+ };
939
+ } catch {
940
+ return null;
941
+ }
942
+ }
702
943
  async function readConfig() {
703
944
  await ensureDir();
945
+ const file = await readConfigFile();
704
946
  let raw = {};
705
- try {
706
- raw = JSON.parse(await readFile(configPath(), "utf8"));
947
+ if (file !== null) try {
948
+ raw = JSON.parse(file.text);
707
949
  } catch {
708
950
  raw = {};
709
951
  }
@@ -712,21 +954,21 @@ async function readConfig() {
712
954
  /**
713
955
  * The config, plus whether it is safe to decide what the store still owns from it.
714
956
  *
715
- * `readConfig` deliberately swallows a corrupt file and answers with defaults —
716
- * right for painting (a broken file must not leave a blank panel) and WRONG for
717
- * the cleanup, which would then see zero referenced slots and treat every image on
718
- * disk as an orphan. That is the one way this feature could delete a user's whole
719
- * wallpaper collection, so the two cases are told apart here and the sweep refuses
720
- * rather than guesses:
721
- *
722
- * - no file at all → trusted. A fresh store has images only if something wrote
723
- * them, and nothing did;
724
- * - a file that parses to an object → trusted (its rules are what they say);
725
- * - a file that is there but does not parse, or is not an object → NOT trusted.
726
- *
727
- * A config whose `rules` are missing entirely is trusted: `normalizeConfig` seeds
728
- * one blank rule for that case, which owns no slot, and the sanitizer's answer is
729
- * exactly what a reload would paint.
957
+ * `readConfig` deliberately swallows a corrupt file and answers with the last good
958
+ * copy (or, when there is none, with defaults) — right for painting and WRONG as a
959
+ * basis for deleting images, because a store whose config could not be read at all
960
+ * would then have zero referenced slots and every file on disk would look like an
961
+ * orphan. That is the one way this feature could destroy a wallpaper collection, so
962
+ * the two cases are told apart here and the sweep refuses rather than guesses:
963
+ *
964
+ * - a live file that parses to an object → trusted (its rules are what they say);
965
+ * - no live file, and no backup either → trusted. A fresh store has images only
966
+ * if something wrote them, and nothing did;
967
+ * - a live file that is there but does not parse, or is not an object → the
968
+ * BACKUP is the answer, and only because it is exactly the config `readConfig`
969
+ * would restore: pruning against it is pruning against the rules the user is
970
+ * about to get back. When there is no parseable backup, this is `untrusted` and
971
+ * the sweep refuses.
730
972
  *
731
973
  * The untrusted branch deliberately does NOT go through `migrateLegacy`: that one
732
974
  * WRITES a repaired config back to disk for the shapes earlier releases left
@@ -737,31 +979,33 @@ async function readConfig() {
737
979
  */
738
980
  async function readConfigForSweep() {
739
981
  await ensureDir();
740
- let text;
982
+ let live = null;
741
983
  try {
742
- text = await readFile(configPath(), "utf8");
984
+ live = await readFile(configPath(), "utf8");
743
985
  } catch {
744
- return {
745
- config: await readConfig(),
746
- trusted: true
747
- };
986
+ live = null;
748
987
  }
749
- let raw;
988
+ if (live !== null && parsesAsConfig(live)) return {
989
+ config: normalizeConfig(await migrateLegacy(JSON.parse(live))),
990
+ trusted: true
991
+ };
992
+ let backup = null;
750
993
  try {
751
- raw = JSON.parse(text);
994
+ backup = await readFile(configBackupPath(), "utf8");
752
995
  } catch {
753
- return {
754
- config: normalizeConfig({}),
755
- trusted: false
756
- };
996
+ backup = null;
757
997
  }
758
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
998
+ if (backup !== null && parsesAsConfig(backup)) return {
999
+ config: normalizeConfig(JSON.parse(backup)),
1000
+ trusted: true
1001
+ };
1002
+ if (live === null && backup === null) return {
759
1003
  config: normalizeConfig({}),
760
- trusted: false
1004
+ trusted: true
761
1005
  };
762
1006
  return {
763
- config: normalizeConfig(await migrateLegacy(raw)),
764
- trusted: true
1007
+ config: normalizeConfig({}),
1008
+ trusted: false
765
1009
  };
766
1010
  }
767
1011
  const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
@@ -775,7 +1019,10 @@ const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
775
1019
  const LEGACY_RULE_KEYS = /* @__PURE__ */ new Set([
776
1020
  "slot",
777
1021
  "bgState",
778
- "bgMode"
1022
+ "bgMode",
1023
+ "wallpaperOpacity",
1024
+ "brightness",
1025
+ "blur"
779
1026
  ]);
780
1027
  const LEGACY_NESTED_KEYS = /* @__PURE__ */ new Set(["rules[].rotate.fadeMs"]);
781
1028
  const warnedConfigKeys = /* @__PURE__ */ new Set();
@@ -829,11 +1076,10 @@ function warnUnknownConfigKeys(raw, normalized) {
829
1076
  }
830
1077
  }
831
1078
  async function writeConfig(config) {
832
- await ensureDir();
833
1079
  try {
834
1080
  const normalized = normalizeConfig(config);
835
1081
  warnUnknownConfigKeys(config, normalized);
836
- await writeFile(configPath(), JSON.stringify(normalized, null, 2), "utf8");
1082
+ await writeConfigFile(serializeConfig(normalized));
837
1083
  return true;
838
1084
  } catch (e) {
839
1085
  console.error(`dsh-background-by-model: failed to write "${CONFIG_FILE}"`, e);
@@ -846,7 +1092,7 @@ async function writeConfig(config) {
846
1092
  *
847
1093
  * The null answer is what a DOWNLOAD is checked against (see `fetchHolidayAsset`):
848
1094
  * neither a status code nor a `content-type` header survives contact with a
849
- * 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
850
1096
  * cannot lie.
851
1097
  */
852
1098
  function imageMimeOrNull(buf) {
@@ -919,10 +1165,12 @@ async function readHolidayAsset(asset) {
919
1165
  holidayInFlight.set(asset, task);
920
1166
  return await task;
921
1167
  }
922
- /** 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. */
923
1169
  async function downloadHolidayAsset(asset) {
924
- for (const host of HOLIDAY_ASSET_HOSTS) {
925
- 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}`);
926
1174
  if (buf === null) continue;
927
1175
  await writeHolidayCache(asset, buf);
928
1176
  return buf;
@@ -983,18 +1231,18 @@ async function fetchAsset(url, max, timeout) {
983
1231
  }
984
1232
  }
985
1233
  /**
986
- * 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
987
1235
  * asking to be left alone, 408 one giving up on us. Every other non-2xx means the
988
- * file is not there (jsDelivr answers 403 outside the repository, 404 for a missing
989
- * 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.
990
1238
  */
991
1239
  function fetchFailureForStatus(status) {
992
1240
  return status >= 500 || status === 429 || status === 408 ? "server" : "missing";
993
1241
  }
994
1242
  /**
995
- * One festival picture, or null when no mirror served an image.
1243
+ * One festival picture, or null when no host served an image.
996
1244
  *
997
- * 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,
998
1246
  * before the first wallpaper is painted, and a retry round would delay that paint.
999
1247
  * The art is cached once it arrives and every later read tries again.
1000
1248
  */
@@ -1003,19 +1251,20 @@ async function fetchHolidayAsset(url) {
1003
1251
  return !got.ok || imageMimeOrNull(got.buf) === null ? null : got.buf;
1004
1252
  }
1005
1253
  /**
1006
- * 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.
1007
1255
  *
1008
- * Every mirror is asked in every round, so a round is "all the hosts there are" and
1009
- * 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
1010
1258
  * time, on the sixth of seven files). A round whose failures were all terminal — the
1011
- * file is gone, or too big — ends the attempts outright, so a renamed file costs two
1012
- * 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).
1013
1262
  */
1014
- async function fetchPresetFile(name, max) {
1263
+ async function fetchProfileFile(path, max) {
1015
1264
  for (let attempt = 1; attempt <= 3; attempt++) {
1016
1265
  let retryable = false;
1017
- for (const host of PRESET_ASSET_HOSTS) {
1018
- 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);
1019
1268
  if (got.ok) return got.buf;
1020
1269
  if (presetRetryable(got.reason)) retryable = true;
1021
1270
  }
@@ -1025,102 +1274,30 @@ async function fetchPresetFile(name, max) {
1025
1274
  return null;
1026
1275
  }
1027
1276
  /**
1028
- * The profile's shape gate and its configuration, in one answer.
1029
- *
1030
- * Two files in order: the manifest says what SHAPE the config is written for, and a
1031
- * config this build cannot read is refused here — before anything is downloaded in
1032
- * 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.
1033
1278
  *
1034
- * The sanitizer runs on this side on purpose: the download list is DERIVED from
1035
- * `rules[].images[].slot`, so an unsanitized config would let a remote file choose
1036
- * what this process requests. After `normalizeConfig` every slot has passed `SLOT_RE`
1037
- * (`normalizeImage`). Holiday slots are then DROPPED rather than refused: they are
1038
- * named legally in `holidays.items`, have no file at all, and `writeImage` refuses
1039
- * 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.
1040
1285
  *
1041
1286
  * Nothing is cached to disk, unlike the festival art: that cache exists because the
1042
1287
  * art is fetched on a schedule and must work offline afterwards, while this is a
1043
1288
  * button whose whole point is to fetch what is there NOW.
1044
1289
  */
1045
- async function fetchPresetConfig() {
1046
- const manifestRaw = await fetchPresetFile(PRESET_MANIFEST, PRESET_MANIFEST_MAX);
1047
- if (manifestRaw === null) return {
1048
- ok: false,
1049
- error: "download failed"
1050
- };
1051
- let manifest;
1052
- try {
1053
- manifest = JSON.parse(manifestRaw.toString("utf8"));
1054
- } catch {
1055
- return {
1056
- ok: false,
1057
- error: "not a profile"
1058
- };
1059
- }
1060
- const declared = manifest?.version;
1061
- if (typeof declared !== "number" || !isFinite(declared)) return {
1062
- ok: false,
1063
- error: "not a profile"
1064
- };
1065
- if (declared > 9) {
1066
- console.warn(`dsh-background-by-model: the recommended profile declares schema ${declared}, this build writes 9`);
1067
- return {
1068
- ok: false,
1069
- error: "newer version"
1070
- };
1071
- }
1072
- if (declared < 9) console.warn(`dsh-background-by-model: the recommended profile is written for schema ${declared}; importing it with defaults for the fields it predates`);
1073
- const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
1074
- if (configRaw === null) return {
1075
- ok: false,
1076
- error: "download failed"
1077
- };
1078
- let raw;
1079
- try {
1080
- raw = JSON.parse(configRaw.toString("utf8"));
1081
- } catch {
1082
- return {
1083
- ok: false,
1084
- error: "not a profile"
1085
- };
1086
- }
1087
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
1088
- ok: false,
1089
- error: "not a profile"
1090
- };
1091
- const config = normalizeConfig(raw);
1092
- const slots = /* @__PURE__ */ new Set();
1093
- for (const rule of config.rules) for (const image of rule.images) {
1094
- if (holidayAssetFor(image.slot) !== null) continue;
1095
- slots.add(image.slot);
1096
- }
1097
- if (slots.size > PRESET_IMAGE_COUNT_MAX) return {
1098
- ok: false,
1099
- error: "too many images"
1100
- };
1101
- return {
1102
- ok: true,
1103
- version: declared,
1104
- config,
1105
- slots: [...slots]
1106
- };
1107
- }
1108
- /**
1109
- * One of the profile's wallpapers, as a data URL ready to be written into a slot.
1110
- *
1111
- * The same two checks the festival art gets, for the same reason: the bytes must
1112
- * actually be an image (`imageMimeOrNull`, never the `content-type` header — the
1113
- * mirror serves extensionless files as `application/octet-stream`), and the slot
1114
- * must be one this process is willing to write. The slot name is re-checked here
1115
- * because this is a public RPC: the list this side hands out is not the only
1116
- * thing a caller could ask for.
1117
- */
1118
1290
  async function fetchPresetImage(slot) {
1119
1291
  if (!SLOT_RE.test(slot) || holidayAssetFor(slot) !== null) return {
1120
1292
  ok: false,
1121
1293
  error: "bad slot"
1122
1294
  };
1123
- 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);
1124
1301
  if (buf === null) return {
1125
1302
  ok: false,
1126
1303
  error: "download failed"
@@ -1145,7 +1322,9 @@ async function fetchPresetImage(slot) {
1145
1322
  async function writeHolidayCache(asset, buf) {
1146
1323
  try {
1147
1324
  await ensureDir();
1148
- 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 });
1149
1328
  const target = holidayCachePath(asset);
1150
1329
  const temp = `${target}.tmp`;
1151
1330
  await writeFile(temp, buf);
@@ -1155,21 +1334,28 @@ async function writeHolidayCache(asset, buf) {
1155
1334
  console.warn(`dsh-background-by-model: could not cache the "${asset}" holiday art`, e);
1156
1335
  }
1157
1336
  }
1337
+ /** The generation directories this build still reads — one per holiday, deduped. */
1338
+ function holidayGenerations() {
1339
+ return [...new Set(HOLIDAYS.map(holidayArtGeneration))];
1340
+ }
1158
1341
  /**
1159
- * 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.
1160
1343
  *
1161
- * Called AFTER a successful download, never before it: a pinned release that turns
1162
- * 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.
1163
1346
  *
1164
- * It walks the cache ROOT and compares GENERATION names, never asset names, so the
1165
- * generation in use cannot be touched. That also makes an upgrade clean up after
1166
- * 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.
1167
1352
  */
1168
1353
  async function pruneHolidayCache() {
1169
1354
  try {
1170
1355
  const root = dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR);
1356
+ const keep = new Set(holidayGenerations());
1171
1357
  for (const entry of await readdir(root)) {
1172
- if (entry === HOLIDAY_ASSETS_REF) continue;
1358
+ if (keep.has(entry)) continue;
1173
1359
  await rm(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, entry), {
1174
1360
  recursive: true,
1175
1361
  force: true
@@ -1216,7 +1402,7 @@ async function listSlots() {
1216
1402
  }
1217
1403
  }
1218
1404
  /**
1219
- * Empty the store: the config and every rule image, and nothing else.
1405
+ * Empty the store: the config, its backup and every rule image, and nothing else.
1220
1406
  *
1221
1407
  * The hard reset behind "use the recommended profile", and it has to be a real one
1222
1408
  * rather than a config overwrite: `nextSlot` hands out `m1`, `m2`, … as a running
@@ -1224,6 +1410,10 @@ async function listSlots() {
1224
1410
  * as a matter of course. Emptying first is what makes the profile's own slots free
1225
1411
  * to write, which is why no slot remapping is needed.
1226
1412
  *
1413
+ * The backup goes WITH the live file: a `.bak` left behind would be restored by the
1414
+ * next read, which would quietly undo the very replacement this call exists to
1415
+ * perform (`applyThemeFile`'s `wipeFirst`).
1416
+ *
1227
1417
  * `holiday-cache/` is deliberately untouched: downloaded art that belongs to no
1228
1418
  * slot and costs a round trip to get back. Pruning generations is
1229
1419
  * `pruneHolidayCache`'s job.
@@ -1234,8 +1424,8 @@ async function listSlots() {
1234
1424
  */
1235
1425
  async function resetStore() {
1236
1426
  let ok = true;
1237
- try {
1238
- await rm(configPath(), { force: true });
1427
+ for (const path of [configPath(), configBackupPath()]) try {
1428
+ await rm(path, { force: true });
1239
1429
  } catch (e) {
1240
1430
  ok = false;
1241
1431
  console.warn(`dsh-background-by-model: failed to remove "${CONFIG_FILE}"`, e);
@@ -1476,7 +1666,7 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1476
1666
  value: {
1477
1667
  config: await readConfig(),
1478
1668
  slots: await listSlots(),
1479
- schema: 9
1669
+ schema: 10
1480
1670
  }
1481
1671
  };
1482
1672
  case "writeConfig": return {
@@ -1522,10 +1712,6 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1522
1712
  ok: true,
1523
1713
  value: await fetchImageUrl(slot, payload?.url ?? null)
1524
1714
  };
1525
- case "fetchPresetConfig": return {
1526
- ok: true,
1527
- value: await fetchPresetConfig()
1528
- };
1529
1715
  case "fetchPresetImage":
1530
1716
  if (slot === null) return {
1531
1717
  ok: true,