dsh-background-by-model 0.9.2 → 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
@@ -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 ?? {};
@@ -508,8 +551,9 @@ function orphanSlots(stored, referenced, kept = []) {
508
551
  * `/api`, which slash commands own).
509
552
  *
510
553
  * 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)
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)
513
557
  * holiday-cache/<tag>/ festival art downloaded from the CDN, one directory per
514
558
  * pinned release, deliberately NOT in the slot namespace
515
559
  *
@@ -521,6 +565,20 @@ const name = "dsh-background-by-model";
521
565
  const inject = ["connection", "webServer"];
522
566
  const DATA_DIR = ".dsh-background-by-model-data";
523
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";
524
582
  const IMAGE_PREFIX = "modelbg-";
525
583
  const LEGACY_WALLPAPER = "wallpaper.jpg";
526
584
  const LEGACY_FILES = [
@@ -622,6 +680,8 @@ const PRESET_IMAGE_COUNT_MAX = 64;
622
680
  const PRESET_FETCH_TIMEOUT = 6e4;
623
681
  const dataDir = () => dshHomePath(DATA_DIR);
624
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}`);
625
685
  const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
626
686
  const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
627
687
  const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, HOLIDAY_ASSETS_REF, asset);
@@ -670,14 +730,15 @@ async function migrateLegacy(raw) {
670
730
  images: [{
671
731
  slot: "m1",
672
732
  bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
673
- 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
674
738
  }],
675
739
  match: "",
676
740
  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
741
+ color: Array.isArray(r.color) ? r.color : null
681
742
  }],
682
743
  opacities: r.opacities,
683
744
  settingsOpacity: r.settingsOpacity,
@@ -686,7 +747,7 @@ async function migrateLegacy(raw) {
686
747
  rightbarOpacity: r.rightbarOpacity
687
748
  };
688
749
  try {
689
- await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
750
+ await writeConfigFile(serializeConfig(next));
690
751
  } catch (e) {
691
752
  console.warn("dsh-background-by-model: could not rewrite the migrated config", e);
692
753
  }
@@ -699,11 +760,121 @@ async function ensureDir() {
699
760
  console.warn(`dsh-background-by-model: cannot create data dir "${dataDir()}"`, e);
700
761
  }
701
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
+ }
702
872
  async function readConfig() {
703
873
  await ensureDir();
874
+ const file = await readConfigFile();
704
875
  let raw = {};
705
- try {
706
- raw = JSON.parse(await readFile(configPath(), "utf8"));
876
+ if (file !== null) try {
877
+ raw = JSON.parse(file.text);
707
878
  } catch {
708
879
  raw = {};
709
880
  }
@@ -712,21 +883,21 @@ async function readConfig() {
712
883
  /**
713
884
  * The config, plus whether it is safe to decide what the store still owns from it.
714
885
  *
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.
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.
730
901
  *
731
902
  * The untrusted branch deliberately does NOT go through `migrateLegacy`: that one
732
903
  * WRITES a repaired config back to disk for the shapes earlier releases left
@@ -737,31 +908,33 @@ async function readConfig() {
737
908
  */
738
909
  async function readConfigForSweep() {
739
910
  await ensureDir();
740
- let text;
911
+ let live = null;
741
912
  try {
742
- text = await readFile(configPath(), "utf8");
913
+ live = await readFile(configPath(), "utf8");
743
914
  } catch {
744
- return {
745
- config: await readConfig(),
746
- trusted: true
747
- };
915
+ live = null;
748
916
  }
749
- let raw;
917
+ if (live !== null && parsesAsConfig(live)) return {
918
+ config: normalizeConfig(await migrateLegacy(JSON.parse(live))),
919
+ trusted: true
920
+ };
921
+ let backup = null;
750
922
  try {
751
- raw = JSON.parse(text);
923
+ backup = await readFile(configBackupPath(), "utf8");
752
924
  } catch {
753
- return {
754
- config: normalizeConfig({}),
755
- trusted: false
756
- };
925
+ backup = null;
757
926
  }
758
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
927
+ if (backup !== null && parsesAsConfig(backup)) return {
928
+ config: normalizeConfig(JSON.parse(backup)),
929
+ trusted: true
930
+ };
931
+ if (live === null && backup === null) return {
759
932
  config: normalizeConfig({}),
760
- trusted: false
933
+ trusted: true
761
934
  };
762
935
  return {
763
- config: normalizeConfig(await migrateLegacy(raw)),
764
- trusted: true
936
+ config: normalizeConfig({}),
937
+ trusted: false
765
938
  };
766
939
  }
767
940
  const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
@@ -775,7 +948,10 @@ const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
775
948
  const LEGACY_RULE_KEYS = /* @__PURE__ */ new Set([
776
949
  "slot",
777
950
  "bgState",
778
- "bgMode"
951
+ "bgMode",
952
+ "wallpaperOpacity",
953
+ "brightness",
954
+ "blur"
779
955
  ]);
780
956
  const LEGACY_NESTED_KEYS = /* @__PURE__ */ new Set(["rules[].rotate.fadeMs"]);
781
957
  const warnedConfigKeys = /* @__PURE__ */ new Set();
@@ -829,11 +1005,10 @@ function warnUnknownConfigKeys(raw, normalized) {
829
1005
  }
830
1006
  }
831
1007
  async function writeConfig(config) {
832
- await ensureDir();
833
1008
  try {
834
1009
  const normalized = normalizeConfig(config);
835
1010
  warnUnknownConfigKeys(config, normalized);
836
- await writeFile(configPath(), JSON.stringify(normalized, null, 2), "utf8");
1011
+ await writeConfigFile(serializeConfig(normalized));
837
1012
  return true;
838
1013
  } catch (e) {
839
1014
  console.error(`dsh-background-by-model: failed to write "${CONFIG_FILE}"`, e);
@@ -1062,14 +1237,14 @@ async function fetchPresetConfig() {
1062
1237
  ok: false,
1063
1238
  error: "not a profile"
1064
1239
  };
1065
- if (declared > 9) {
1066
- 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`);
1067
1242
  return {
1068
1243
  ok: false,
1069
1244
  error: "newer version"
1070
1245
  };
1071
1246
  }
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`);
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`);
1073
1248
  const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
1074
1249
  if (configRaw === null) return {
1075
1250
  ok: false,
@@ -1216,7 +1391,7 @@ async function listSlots() {
1216
1391
  }
1217
1392
  }
1218
1393
  /**
1219
- * 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.
1220
1395
  *
1221
1396
  * The hard reset behind "use the recommended profile", and it has to be a real one
1222
1397
  * rather than a config overwrite: `nextSlot` hands out `m1`, `m2`, … as a running
@@ -1224,6 +1399,10 @@ async function listSlots() {
1224
1399
  * as a matter of course. Emptying first is what makes the profile's own slots free
1225
1400
  * to write, which is why no slot remapping is needed.
1226
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
+ *
1227
1406
  * `holiday-cache/` is deliberately untouched: downloaded art that belongs to no
1228
1407
  * slot and costs a round trip to get back. Pruning generations is
1229
1408
  * `pruneHolidayCache`'s job.
@@ -1234,8 +1413,8 @@ async function listSlots() {
1234
1413
  */
1235
1414
  async function resetStore() {
1236
1415
  let ok = true;
1237
- try {
1238
- await rm(configPath(), { force: true });
1416
+ for (const path of [configPath(), configBackupPath()]) try {
1417
+ await rm(path, { force: true });
1239
1418
  } catch (e) {
1240
1419
  ok = false;
1241
1420
  console.warn(`dsh-background-by-model: failed to remove "${CONFIG_FILE}"`, e);
@@ -1476,7 +1655,7 @@ async function handleRpcMethod(ctx, endpoint, payload) {
1476
1655
  value: {
1477
1656
  config: await readConfig(),
1478
1657
  slots: await listSlots(),
1479
- schema: 9
1658
+ schema: 10
1480
1659
  }
1481
1660
  };
1482
1661
  case "writeConfig": return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-background-by-model",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Appearance plugin for DeepSeek Harness: an ordered list of model rules, each carrying its own wallpapers — every image with its own framing and theme color — plus layout mode, opacity, brightness and blur; the background follows the model you switch to, and a rule can rotate through several images on its own rhythm.",
5
5
  "keywords": [
6
6
  "deepseek-harness",