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/CHANGELOG.md +27 -1
- package/CHANGELOG.zh.md +27 -1
- package/README.md +41 -19
- package/README.zh.md +42 -20
- package/lib/client.js +634 -133
- package/lib/index.js +441 -62
- package/package.json +3 -2
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`
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
*
|
|
477
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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 >
|
|
976
|
-
console.warn(`dsh-background-by-model: the recommended profile declares schema ${declared}, this build writes
|
|
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 <
|
|
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(
|
|
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:
|
|
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)
|