dsh-background-by-model 0.9.1 → 0.10.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
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
3
3
  //#region src/holiday.ts
4
4
  const HOLIDAYS = [{
@@ -108,6 +108,28 @@ const DEFAULT_TRANSITION = {
108
108
  easing: "ease",
109
109
  durationMs: 320
110
110
  };
111
+ /**
112
+ * One image entry for a place that CREATES a picture rather than reads one back
113
+ * (a fresh holiday, and every upload in the panel).
114
+ *
115
+ * The two effects whose neutral value is also the shipped default are written
116
+ * explicitly (`opacity`, `blur`), even though `normalizeImage` would default them:
117
+ * the read-time lifts key off the ABSENCE of a key, so an entry created without one
118
+ * would come back as "written before this field existed" and inherit whatever the
119
+ * rule happened to carry. `brightness` is the one field whose absence is meaningful
120
+ * (see `BgImage.brightness`), so it is left out — "untouched" is exactly what a new
121
+ * picture is.
122
+ */
123
+ function defaultImage(slot, color = null, bgMode = "fit") {
124
+ return {
125
+ slot,
126
+ bgMode,
127
+ bgState: { ...DEFAULT_BG_STATE },
128
+ color,
129
+ opacity: 1,
130
+ blur: 0
131
+ };
132
+ }
111
133
  const DEFAULT_CHAT_TEXT_OPACITY = .35;
112
134
  const DEFAULT_TRAJECTORY_OPACITY = .73;
113
135
  const DEFAULT_RIGHTBAR_OPACITY = .45;
@@ -156,18 +178,10 @@ function defaultHolidayRule(def) {
156
178
  const color = hexToHsl(def.color);
157
179
  return {
158
180
  id: def.id,
159
- images: [{
160
- slot: def.slot,
161
- bgMode: "fill",
162
- bgState: { ...DEFAULT_BG_STATE },
163
- color
164
- }],
181
+ images: [defaultImage(def.slot, color, "fill")],
165
182
  match: "",
166
183
  enabled: true,
167
184
  color,
168
- wallpaperOpacity: 1,
169
- brightness: 100,
170
- blur: 0,
171
185
  rotate: defaultRotation()
172
186
  };
173
187
  }
@@ -187,9 +201,6 @@ function blankRule(id) {
187
201
  match: "",
188
202
  enabled: true,
189
203
  color: null,
190
- wallpaperOpacity: 1,
191
- brightness: 100,
192
- blur: 0,
193
204
  rotate: defaultRotation()
194
205
  };
195
206
  }
@@ -276,28 +287,41 @@ function normalizeTransition(raw, legacyMs) {
276
287
  durationMs: clamp(t.durationMs, 0, TRANSITION_MAX_MS, fallback)
277
288
  };
278
289
  }
290
+ /** The inherit block for a caller that has no rule-level values at all. */
291
+ const DEFAULT_IMAGE_INHERIT = {
292
+ color: null,
293
+ opacity: 1,
294
+ brightness: void 0,
295
+ blur: 0
296
+ };
279
297
  /**
280
298
  * Coerce one persisted image entry, or null when it lacks a usable slot.
281
299
  *
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.
300
+ * `inherit` is the rule-level block this entry may take its values from, and each
301
+ * field is lifted ONLY when the entry has no key of its own — that absence is the
302
+ * whole signal. `color: null` is a deliberate "follow the system theme" and must
303
+ * not be re-inherited on every load; `brightness` keeps its key absent when neither
304
+ * side ever named one, so an upgrade never writes a default nobody chose.
286
305
  *
287
306
  * `inheritMode` is the same one-shot lift for the layout mode. It is the caller's to
288
307
  * resolve because the callers answer differently: a user rule passes the mode its
289
308
  * config carried at the RULE level (schema 5), a holiday passes its own `fill`. An
290
309
  * entry that HAS a `bgMode` key keeps it, even when it equals the default.
291
310
  */
292
- function normalizeImage(raw, inherit = null, inheritMode = "fit") {
311
+ function normalizeImage(raw, inherit = DEFAULT_IMAGE_INHERIT, inheritMode = "fit") {
293
312
  const i = raw ?? {};
294
313
  if (typeof i.slot !== "string" || !SLOT_RE.test(i.slot)) return null;
295
- return {
314
+ const image = {
296
315
  slot: i.slot,
297
316
  bgMode: BG_MODES.includes(i.bgMode) ? i.bgMode : inheritMode,
298
317
  bgState: normalizeBgState(i.bgState),
299
- color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit
318
+ color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit.color,
319
+ opacity: clamp01(i.opacity, inherit.opacity),
320
+ blur: clamp(i.blur, 0, 60, inherit.blur)
300
321
  };
322
+ const brightness = i.brightness === void 0 ? inherit.brightness : clamp(i.brightness, 0, 200, 100);
323
+ if (brightness !== void 0) image.brightness = brightness;
324
+ return image;
301
325
  }
302
326
  /**
303
327
  * Coerce one persisted rule, or null when it names no image ANYWHERE.
@@ -322,38 +346,45 @@ function normalizeRule(raw) {
322
346
  if (id === null) return null;
323
347
  const color = normalizeHsl(r.color);
324
348
  const mode = BG_MODES.includes(r.bgMode) ? r.bgMode : "fit";
349
+ const inherit = {
350
+ color,
351
+ opacity: clamp01(r.wallpaperOpacity, 1),
352
+ brightness: r.brightness === void 0 ? void 0 : clamp(r.brightness, 0, 200, 100),
353
+ blur: clamp(r.blur, 0, 60, 0)
354
+ };
325
355
  const images = [];
326
356
  const seen = /* @__PURE__ */ new Set();
327
357
  const hadList = Array.isArray(r.images);
328
358
  if (hadList) for (const entry of r.images) {
329
- const image = normalizeImage(entry, color, mode);
359
+ const image = normalizeImage(entry, inherit, mode);
330
360
  if (image === null || seen.has(image.slot)) continue;
331
361
  seen.add(image.slot);
332
362
  images.push(image);
333
363
  }
334
364
  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
- });
365
+ if (images.length === 0 && legacySlot !== null) {
366
+ const image = {
367
+ slot: legacySlot,
368
+ bgMode: mode,
369
+ bgState: normalizeBgState(r.bgState),
370
+ color,
371
+ opacity: inherit.opacity,
372
+ blur: inherit.blur
373
+ };
374
+ if (inherit.brightness !== void 0) image.brightness = inherit.brightness;
375
+ images.push(image);
376
+ }
341
377
  if (images.length === 0 && !hadList) return null;
342
378
  const rotate = normalizeRotation(r.rotate);
343
379
  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 = {
380
+ return {
346
381
  id,
347
382
  images,
348
383
  match: typeof r.match === "string" ? r.match : "",
349
384
  enabled: r.enabled !== false,
350
385
  color,
351
- wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
352
- blur: clamp(r.blur, 0, 60, 0),
353
386
  rotate
354
387
  };
355
- if (brightness !== void 0) rule.brightness = brightness;
356
- return rule;
357
388
  }
358
389
  /**
359
390
  * Coerce one persisted holiday entry.
@@ -370,17 +401,29 @@ function normalizeHolidayRule(def, raw) {
370
401
  return {
371
402
  ...base,
372
403
  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
- }))
404
+ images: base.images.map((image) => {
405
+ const stored = r.images?.[0];
406
+ const merged = {
407
+ slot: image.slot,
408
+ bgMode: image.bgMode,
409
+ bgState: normalizeBgState(stored?.bgState ?? r.bgState),
410
+ color: image.color,
411
+ opacity: clamp01(stored?.opacity ?? legacyWallpaperOpacity(r.wallpaperOpacity), image.opacity),
412
+ blur: clamp(stored?.blur ?? r.blur, 0, 60, image.blur)
413
+ };
414
+ const brightness = stored?.brightness === void 0 ? r.brightness === void 0 ? void 0 : clamp(r.brightness, 0, 200, 100) : clamp(stored.brightness, 0, 200, 100);
415
+ if (brightness !== void 0) merged.brightness = brightness;
416
+ return merged;
417
+ })
382
418
  };
383
419
  }
420
+ /**
421
+ * The rule-level opacity a schema-9 holiday entry carried, or `undefined` so the
422
+ * caller can tell "absent" from "set to 1" and leave `clamp01` its default.
423
+ */
424
+ function legacyWallpaperOpacity(raw) {
425
+ return typeof raw === "number" && isFinite(raw) ? raw : void 0;
426
+ }
384
427
  /** Coerce the whole holiday block; unknown ids are dropped, missing ones defaulted. */
385
428
  function normalizeHolidays(raw) {
386
429
  const r = raw ?? {};
@@ -464,6 +507,41 @@ function presetRetryDelayMs(failedAttempt) {
464
507
  return 400 * 2 ** Math.max(0, failedAttempt - 1);
465
508
  }
466
509
  //#endregion
510
+ //#region src/store.ts
511
+ /**
512
+ * Every slot the config still points at, in first-seen order and without repeats.
513
+ *
514
+ * Rules only. A holiday's slot is deliberately NOT included: its art lives in
515
+ * `holiday-cache/`, it is never written to the slot namespace, and a
516
+ * `modelbg-h-*` file therefore cannot belong to anything (see the read-only
517
+ * invariant in MAINTAINING §7.1). Counting the slot as referenced would make such
518
+ * a file permanently unprunable — the one residue this cleanup could never clear.
519
+ */
520
+ function referencedSlots(rules) {
521
+ const seen = /* @__PURE__ */ new Set();
522
+ const out = [];
523
+ for (const rule of rules) for (const image of rule.images) {
524
+ if (typeof image.slot !== "string" || seen.has(image.slot)) continue;
525
+ seen.add(image.slot);
526
+ out.push(image.slot);
527
+ }
528
+ return out;
529
+ }
530
+ /**
531
+ * The files whose slot appears in neither `referenced` nor `kept`.
532
+ *
533
+ * `stored` order is preserved because the caller deletes in it and reports what it
534
+ * removed in that order, which is the only order the user has ever seen these files
535
+ * in. `kept` exists for one reason: the browser half's live config can be a few
536
+ * hundred milliseconds ahead of its own write to disk (a slot is written, then the
537
+ * config that names it), and an image whose bytes have landed but whose rule entry
538
+ * has not must not be deleted in that window.
539
+ */
540
+ function orphanSlots(stored, referenced, kept = []) {
541
+ const live = /* @__PURE__ */ new Set([...referenced, ...kept]);
542
+ return stored.filter((slot) => !live.has(slot));
543
+ }
544
+ //#endregion
467
545
  //#region src/index.ts
468
546
  /**
469
547
  * Node half of dsh-background-by-model: file-backed rule persistence.
@@ -473,8 +551,9 @@ function presetRetryDelayMs(failedAttempt) {
473
551
  * `/api`, which slash commands own).
474
552
  *
475
553
  * theme-config.json the ordered rule list + the global interface settings
476
- * modelbg-<slot> one background image per rule (raw bytes, no extension;
477
- * the MIME is sniffed from the magic bytes when served)
554
+ * theme-config.json.bak the last config that parsed, rotated in before every save
555
+ * modelbg-<slot> one background image (raw bytes, no extension; the MIME is
556
+ * sniffed from the magic bytes when served)
478
557
  * holiday-cache/<tag>/ festival art downloaded from the CDN, one directory per
479
558
  * pinned release, deliberately NOT in the slot namespace
480
559
  *
@@ -486,6 +565,20 @@ const name = "dsh-background-by-model";
486
565
  const inject = ["connection", "webServer"];
487
566
  const DATA_DIR = ".dsh-background-by-model-data";
488
567
  const CONFIG_FILE = "theme-config.json";
568
+ /**
569
+ * The last config that was known to parse, kept beside the live one.
570
+ *
571
+ * A config file is the whole rule list, and it is rewritten on every edit. Writing
572
+ * it in place meant a crash, a full disk or a pulled power cable could leave half a
573
+ * JSON document behind — which `readConfig` greets by silently falling back to the
574
+ * defaults, so the next slider drag would persist those defaults over the ruined
575
+ * file and the original rules were gone for good. Two pieces fix that, and they are
576
+ * one feature: the write is atomic (temp + rename), and the previous good file is
577
+ * rotated to `.bak` before it is replaced, so even a lost file has a copy.
578
+ */
579
+ const CONFIG_BACKUP = "theme-config.json.bak";
580
+ /** Suffix of the in-flight write. Deliberately outside `listSlots`' `modelbg-` prefix. */
581
+ const CONFIG_TEMP = ".tmp";
489
582
  const IMAGE_PREFIX = "modelbg-";
490
583
  const LEGACY_WALLPAPER = "wallpaper.jpg";
491
584
  const LEGACY_FILES = [
@@ -587,6 +680,8 @@ const PRESET_IMAGE_COUNT_MAX = 64;
587
680
  const PRESET_FETCH_TIMEOUT = 6e4;
588
681
  const dataDir = () => dshHomePath(DATA_DIR);
589
682
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
683
+ const configBackupPath = () => dshHomePath(DATA_DIR, CONFIG_BACKUP);
684
+ const configTempPath = () => dshHomePath(DATA_DIR, `${CONFIG_FILE}${CONFIG_TEMP}`);
590
685
  const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
591
686
  const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
592
687
  const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF, asset);
@@ -635,14 +730,15 @@ async function migrateLegacy(raw) {
635
730
  images: [{
636
731
  slot: "m1",
637
732
  bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
638
- bgState: r.bgState ?? {}
733
+ bgState: r.bgState ?? {},
734
+ color: Array.isArray(r.color) ? r.color : null,
735
+ opacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
736
+ ...typeof r.brightness === "number" ? { brightness: r.brightness } : {},
737
+ blur: typeof r.blur === "number" ? r.blur : 0
639
738
  }],
640
739
  match: "",
641
740
  enabled: true,
642
- color: Array.isArray(r.color) ? r.color : null,
643
- wallpaperOpacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
644
- brightness: typeof r.brightness === "number" ? r.brightness : 100,
645
- blur: typeof r.blur === "number" ? r.blur : 0
741
+ color: Array.isArray(r.color) ? r.color : null
646
742
  }],
647
743
  opacities: r.opacities,
648
744
  settingsOpacity: r.settingsOpacity,
@@ -651,7 +747,7 @@ async function migrateLegacy(raw) {
651
747
  rightbarOpacity: r.rightbarOpacity
652
748
  };
653
749
  try {
654
- await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
750
+ await writeConfigFile(serializeConfig(next));
655
751
  } catch (e) {
656
752
  console.warn("dsh-background-by-model: could not rewrite the migrated config", e);
657
753
  }
@@ -664,16 +760,183 @@ async function ensureDir() {
664
760
  console.warn(`dsh-background-by-model: cannot create data dir "${dataDir()}"`, e);
665
761
  }
666
762
  }
763
+ /** The exact bytes a config file should hold. */
764
+ function serializeConfig(config) {
765
+ return JSON.stringify(config, null, 2);
766
+ }
767
+ /**
768
+ * Whether a string is a config this build would accept: JSON, and an object.
769
+ *
770
+ * Deliberately looser than `normalizeConfig` (which accepts anything and defaults
771
+ * every field): this is the test for "is there something worth keeping", so a
772
+ * schema-3 file and a hand-edited one both count. What it rejects is the two ways a
773
+ * write can be interrupted — half a document, and an empty one.
774
+ */
775
+ function parsesAsConfig(text) {
776
+ try {
777
+ const value = JSON.parse(text);
778
+ return value !== null && typeof value === "object" && !Array.isArray(value);
779
+ } catch {
780
+ return false;
781
+ }
782
+ }
783
+ /**
784
+ * Write one config file so that no reader can ever see a partial document, keeping
785
+ * the previous good one as `.bak`.
786
+ *
787
+ * Two steps, in this order, because the order is what makes the copy worth having:
788
+ *
789
+ * 1. the file on disk NOW is copied to `theme-config.json.bak` — but only when it
790
+ * parses. A corrupted live file must never overwrite the last good backup, or
791
+ * the first write after the corruption would also destroy the copy that could
792
+ * have replaced it;
793
+ * 2. the new bytes go to a temp name and are RENAMED over the live path. A rename
794
+ * is atomic within one directory, so a crash either leaves the old file or the
795
+ * new one — never half of either.
796
+ *
797
+ * The rename is not `writeFile` for the same reason: writing in place truncates
798
+ * first, and a crash between the truncation and the last byte is exactly the state
799
+ * that used to eat a rule list.
800
+ */
801
+ async function writeConfigFile(text) {
802
+ await ensureDir();
803
+ const target = configPath();
804
+ try {
805
+ const current = await readFile(target, "utf8");
806
+ if (parsesAsConfig(current)) {
807
+ const backupTemp = `${configBackupPath()}${CONFIG_TEMP}`;
808
+ await writeFile(backupTemp, current, "utf8");
809
+ await renameOver(backupTemp, configBackupPath());
810
+ }
811
+ } catch {}
812
+ const temp = configTempPath();
813
+ await writeFile(temp, text, "utf8");
814
+ await renameOver(temp, target);
815
+ }
816
+ /**
817
+ * `rename` over an existing file, which replaces it atomically on POSIX.
818
+ *
819
+ * Windows refuses to rename onto a name that exists (EEXIST / EPERM), so the one
820
+ * platform that cannot do this in one step gets a two-step fallback — and the
821
+ * difference matters: unlinking first leaves a window with NO file, which is the
822
+ * state this whole function exists to avoid, so on POSIX the unlink must never be
823
+ * attempted. The fallback is only reached when the rename itself said the target is
824
+ * in the way, which is exactly the Windows case.
825
+ */
826
+ async function renameOver(from, to) {
827
+ try {
828
+ await rename(from, to);
829
+ } catch (e) {
830
+ const code = e?.code;
831
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY") throw e;
832
+ await rm(to, { force: true });
833
+ await rename(from, to);
834
+ }
835
+ }
836
+ /**
837
+ * The last good config that can be restored, as raw text — or null when there is
838
+ * none.
839
+ *
840
+ * Two candidates, in order of authority: the live file (when it parses, which is
841
+ * the ordinary case and costs one read) and the `.bak`. The backup is only
842
+ * consulted for a live file that is missing or corrupt, so a half-written document
843
+ * from an interrupted save is answered with the copy rather than with the shipped
844
+ * defaults — which is the difference between "your rules are back" and "your rules
845
+ * were never there".
846
+ */
847
+ async function readConfigFile() {
848
+ try {
849
+ const text = await readFile(configPath(), "utf8");
850
+ if (parsesAsConfig(text)) return {
851
+ text,
852
+ source: "live"
853
+ };
854
+ } catch {}
855
+ try {
856
+ const text = await readFile(configBackupPath(), "utf8");
857
+ if (!parsesAsConfig(text)) return null;
858
+ try {
859
+ await writeConfigFile(text);
860
+ } catch (e) {
861
+ console.warn("dsh-background-by-model: could not restore the config from its backup", e);
862
+ }
863
+ console.warn("dsh-background-by-model: \"theme-config.json\" was missing or unreadable; restored the last saved copy from \"theme-config.json.bak\"");
864
+ return {
865
+ text,
866
+ source: "backup"
867
+ };
868
+ } catch {
869
+ return null;
870
+ }
871
+ }
667
872
  async function readConfig() {
668
873
  await ensureDir();
874
+ const file = await readConfigFile();
669
875
  let raw = {};
670
- try {
671
- raw = JSON.parse(await readFile(configPath(), "utf8"));
876
+ if (file !== null) try {
877
+ raw = JSON.parse(file.text);
672
878
  } catch {
673
879
  raw = {};
674
880
  }
675
881
  return normalizeConfig(await migrateLegacy(raw));
676
882
  }
883
+ /**
884
+ * The config, plus whether it is safe to decide what the store still owns from it.
885
+ *
886
+ * `readConfig` deliberately swallows a corrupt file and answers with the last good
887
+ * copy (or, when there is none, with defaults) — right for painting and WRONG as a
888
+ * basis for deleting images, because a store whose config could not be read at all
889
+ * would then have zero referenced slots and every file on disk would look like an
890
+ * orphan. That is the one way this feature could destroy a wallpaper collection, so
891
+ * the two cases are told apart here and the sweep refuses rather than guesses:
892
+ *
893
+ * - a live file that parses to an object → trusted (its rules are what they say);
894
+ * - no live file, and no backup either → trusted. A fresh store has images only
895
+ * if something wrote them, and nothing did;
896
+ * - a live file that is there but does not parse, or is not an object → the
897
+ * BACKUP is the answer, and only because it is exactly the config `readConfig`
898
+ * would restore: pruning against it is pruning against the rules the user is
899
+ * about to get back. When there is no parseable backup, this is `untrusted` and
900
+ * the sweep refuses.
901
+ *
902
+ * The untrusted branch deliberately does NOT go through `migrateLegacy`: that one
903
+ * WRITES a repaired config back to disk for the shapes earlier releases left
904
+ * behind, and running it here would turn "refuse to delete" into "quietly rewrite
905
+ * whatever the user's broken file said, then delete against the rewrite". The
906
+ * display answer for an unreadable file is the sanitizer's defaults, computed
907
+ * without touching the disk.
908
+ */
909
+ async function readConfigForSweep() {
910
+ await ensureDir();
911
+ let live = null;
912
+ try {
913
+ live = await readFile(configPath(), "utf8");
914
+ } catch {
915
+ live = null;
916
+ }
917
+ if (live !== null && parsesAsConfig(live)) return {
918
+ config: normalizeConfig(await migrateLegacy(JSON.parse(live))),
919
+ trusted: true
920
+ };
921
+ let backup = null;
922
+ try {
923
+ backup = await readFile(configBackupPath(), "utf8");
924
+ } catch {
925
+ backup = null;
926
+ }
927
+ if (backup !== null && parsesAsConfig(backup)) return {
928
+ config: normalizeConfig(JSON.parse(backup)),
929
+ trusted: true
930
+ };
931
+ if (live === null && backup === null) return {
932
+ config: normalizeConfig({}),
933
+ trusted: true
934
+ };
935
+ return {
936
+ config: normalizeConfig({}),
937
+ trusted: false
938
+ };
939
+ }
677
940
  const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
678
941
  "color",
679
942
  "bgMode",
@@ -685,7 +948,10 @@ const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
685
948
  const LEGACY_RULE_KEYS = /* @__PURE__ */ new Set([
686
949
  "slot",
687
950
  "bgState",
688
- "bgMode"
951
+ "bgMode",
952
+ "wallpaperOpacity",
953
+ "brightness",
954
+ "blur"
689
955
  ]);
690
956
  const LEGACY_NESTED_KEYS = /* @__PURE__ */ new Set(["rules[].rotate.fadeMs"]);
691
957
  const warnedConfigKeys = /* @__PURE__ */ new Set();
@@ -739,11 +1005,10 @@ function warnUnknownConfigKeys(raw, normalized) {
739
1005
  }
740
1006
  }
741
1007
  async function writeConfig(config) {
742
- await ensureDir();
743
1008
  try {
744
1009
  const normalized = normalizeConfig(config);
745
1010
  warnUnknownConfigKeys(config, normalized);
746
- await writeFile(configPath(), JSON.stringify(normalized, null, 2), "utf8");
1011
+ await writeConfigFile(serializeConfig(normalized));
747
1012
  return true;
748
1013
  } catch (e) {
749
1014
  console.error(`dsh-background-by-model: failed to write "${CONFIG_FILE}"`, e);
@@ -972,14 +1237,14 @@ async function fetchPresetConfig() {
972
1237
  ok: false,
973
1238
  error: "not a profile"
974
1239
  };
975
- if (declared > 9) {
976
- console.warn(`dsh-background-by-model: the recommended profile declares schema ${declared}, this build writes 9`);
1240
+ if (declared > 10) {
1241
+ console.warn(`dsh-background-by-model: the recommended profile declares schema ${declared}, this build writes 10`);
977
1242
  return {
978
1243
  ok: false,
979
1244
  error: "newer version"
980
1245
  };
981
1246
  }
982
- 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`);
1247
+ if (declared < 10) console.warn(`dsh-background-by-model: the recommended profile is written for schema ${declared}; importing it with defaults for the fields it predates`);
983
1248
  const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
984
1249
  if (configRaw === null) return {
985
1250
  ok: false,
@@ -1126,7 +1391,7 @@ async function listSlots() {
1126
1391
  }
1127
1392
  }
1128
1393
  /**
1129
- * Empty the store: the config and every rule image, and nothing else.
1394
+ * Empty the store: the config, its backup and every rule image, and nothing else.
1130
1395
  *
1131
1396
  * The hard reset behind "use the recommended profile", and it has to be a real one
1132
1397
  * rather than a config overwrite: `nextSlot` hands out `m1`, `m2`, … as a running
@@ -1134,6 +1399,10 @@ async function listSlots() {
1134
1399
  * as a matter of course. Emptying first is what makes the profile's own slots free
1135
1400
  * to write, which is why no slot remapping is needed.
1136
1401
  *
1402
+ * The backup goes WITH the live file: a `.bak` left behind would be restored by the
1403
+ * next read, which would quietly undo the very replacement this call exists to
1404
+ * perform (`applyThemeFile`'s `wipeFirst`).
1405
+ *
1137
1406
  * `holiday-cache/` is deliberately untouched: downloaded art that belongs to no
1138
1407
  * slot and costs a round trip to get back. Pruning generations is
1139
1408
  * `pruneHolidayCache`'s job.
@@ -1144,8 +1413,8 @@ async function listSlots() {
1144
1413
  */
1145
1414
  async function resetStore() {
1146
1415
  let ok = true;
1147
- try {
1148
- await rm(configPath(), { force: true });
1416
+ for (const path of [configPath(), configBackupPath()]) try {
1417
+ await rm(path, { force: true });
1149
1418
  } catch (e) {
1150
1419
  ok = false;
1151
1420
  console.warn(`dsh-background-by-model: failed to remove "${CONFIG_FILE}"`, e);
@@ -1159,6 +1428,106 @@ async function resetStore() {
1159
1428
  return ok;
1160
1429
  }
1161
1430
  /**
1431
+ * What the image store occupies, and which of it nothing points at any more.
1432
+ *
1433
+ * Read-only: it reports, it never deletes. Splitting the two is what lets the panel
1434
+ * show a number before asking, and it is also the only honest way to answer "how
1435
+ * much would this free" — a cleanup whose size is announced AFTER the fact is a
1436
+ * cleanup nobody agrees to.
1437
+ *
1438
+ * `keep` is the browser half's own slot list. It is unioned with the config ON DISK
1439
+ * rather than replacing it because the two are not written at the same moment: an
1440
+ * upload writes the bytes first and the config that names them a moment later, so a
1441
+ * read landing in between would see a file the store is about to legitimately own.
1442
+ * The union can only ever make the report SMALLER (a slot is spared), which is the
1443
+ * safe direction for something whose answer is "delete these files".
1444
+ *
1445
+ * A file that cannot be stat'd is still listed, at 0 bytes: it exists as far as
1446
+ * `readdir` is concerned, and hiding it would leave a residue the panel claims is
1447
+ * not there.
1448
+ *
1449
+ * `trusted` is false when the config file on disk is there but unreadable (see
1450
+ * `readConfigForSweep`). The numbers are then still reported — the user asked what
1451
+ * the store occupies, and that question has an answer — but the caller must not
1452
+ * delete: with no trustworthy rule list, EVERY file would look like an orphan.
1453
+ */
1454
+ async function storeUsage(keep) {
1455
+ const stored = await listSlots();
1456
+ const { config, trusted } = await readConfigForSweep();
1457
+ const orphans = orphanSlots(stored, referencedSlots(config.rules), keep);
1458
+ const sizes = /* @__PURE__ */ new Map();
1459
+ let totalBytes = 0;
1460
+ for (const slot of stored) {
1461
+ let bytes = 0;
1462
+ try {
1463
+ bytes = (await stat(imagePath(slot))).size;
1464
+ } catch {}
1465
+ sizes.set(slot, bytes);
1466
+ totalBytes += bytes;
1467
+ }
1468
+ let orphanBytes = 0;
1469
+ for (const slot of orphans) orphanBytes += sizes.get(slot) ?? 0;
1470
+ return {
1471
+ totalBytes,
1472
+ usedBytes: totalBytes - orphanBytes,
1473
+ orphanBytes,
1474
+ files: stored.length,
1475
+ slots: stored,
1476
+ orphans: orphans.map((slot) => ({
1477
+ slot,
1478
+ bytes: sizes.get(slot) ?? 0
1479
+ })),
1480
+ trusted
1481
+ };
1482
+ }
1483
+ /**
1484
+ * Delete every stored image nothing points at, and report what was removed.
1485
+ *
1486
+ * `keep` is the caller's live slot list, with the same meaning and the same
1487
+ * union-with-disk rule as `storeUsage`. The answer carries the freed byte count so
1488
+ * the panel can say what happened, and `failed` names the slots that resisted —
1489
+ * a cleanup that removed six of seven files and said "done" would leave the user
1490
+ * with a number that never goes down and no way to learn why.
1491
+ *
1492
+ * Failures are per file and never abort the sweep: one locked file must not protect
1493
+ * the other six. Nothing else in the store is touched — the config, the festival art
1494
+ * and the released generations under `holiday-cache/` are all outside this walk.
1495
+ *
1496
+ * REFUSES on an unreadable config (`trusted: false`): every file would look
1497
+ * unreferenced, and this is the one operation in the plugin that could delete a
1498
+ * whole wallpaper collection in a single press. A refusal costs a number on a card;
1499
+ * guessing costs the user their pictures.
1500
+ */
1501
+ async function pruneOrphans(keep) {
1502
+ const usage = await storeUsage(keep);
1503
+ if (!usage.trusted) {
1504
+ console.warn("dsh-background-by-model: refusing to prune — \"theme-config.json\" is unreadable, so nothing on disk can be told apart from an orphan");
1505
+ return {
1506
+ removed: [],
1507
+ failed: [],
1508
+ freedBytes: 0,
1509
+ refused: true
1510
+ };
1511
+ }
1512
+ const removed = [];
1513
+ const failed = [];
1514
+ let freedBytes = 0;
1515
+ for (const entry of usage.orphans) try {
1516
+ await rm(imagePath(entry.slot), { force: true });
1517
+ removed.push(entry.slot);
1518
+ freedBytes += entry.bytes;
1519
+ } catch (e) {
1520
+ failed.push(entry.slot);
1521
+ console.warn(`dsh-background-by-model: failed to remove the orphaned image for slot "${entry.slot}"`, e);
1522
+ }
1523
+ return {
1524
+ removed,
1525
+ failed,
1526
+ freedBytes,
1527
+ refused: false
1528
+ };
1529
+ }
1530
+ /**
1162
1531
  * Download an image from a network URL into one slot (replacing its bytes).
1163
1532
  * Returns { ok, dataUrl?, error? }; never throws.
1164
1533
  */
@@ -1277,6 +1646,8 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1277
1646
  const method = endpoint.startsWith(`${RPC_NS}/`) ? endpoint.slice(21) : "";
1278
1647
  const rawSlot = payload?.slot;
1279
1648
  const slot = typeof rawSlot === "string" && SLOT_RE.test(rawSlot) ? rawSlot : null;
1649
+ const rawKeep = payload?.keep;
1650
+ const keep = Array.isArray(rawKeep) ? rawKeep.filter((s) => typeof s === "string" && SLOT_RE.test(s)) : [];
1280
1651
  try {
1281
1652
  switch (method) {
1282
1653
  case "read": return {
@@ -1284,7 +1655,7 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1284
1655
  value: {
1285
1656
  config: await readConfig(),
1286
1657
  slots: await listSlots(),
1287
- schema: 9
1658
+ schema: 10
1288
1659
  }
1289
1660
  };
1290
1661
  case "writeConfig": return {
@@ -1350,6 +1721,14 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1350
1721
  ok: true,
1351
1722
  value: await resetStore()
1352
1723
  };
1724
+ case "storeUsage": return {
1725
+ ok: true,
1726
+ value: await storeUsage(keep)
1727
+ };
1728
+ case "pruneOrphans": return {
1729
+ ok: true,
1730
+ value: await pruneOrphans(keep)
1731
+ };
1353
1732
  case "defaultModel": return {
1354
1733
  ok: true,
1355
1734
  value: defaultModel(ctx)