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