dsh-background-by-model 0.7.4 → 0.7.6
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 +23 -1
- package/CHANGELOG.zh.md +23 -1
- package/README.md +62 -35
- package/README.zh.md +62 -35
- package/lib/client.js +1393 -556
- package/lib/index.js +483 -37
- package/package.json +6 -6
- package/example_img/rule-editor.webp +0 -0
- package/example_img/wallpaper-deepseek.webp +0 -0
- package/example_img/wallpaper-kimi.webp +0 -0
- package/holiday/mid-autumn.webp +0 -0
- package/holiday/national-day.webp +0 -0
- package/lib/client.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
2
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
3
|
//#region src/holiday.ts
|
|
5
4
|
const HOLIDAYS = [{
|
|
@@ -76,18 +75,22 @@ function defaultRotation() {
|
|
|
76
75
|
* `sidebar` sidebar (`--dsw-specific-sidebar-fill`)
|
|
77
76
|
* `card` cards/panels (`--dsw-alias-bg-layer-1/2/3`, `--dsw-specific-menu`
|
|
78
77
|
* and its 0.1.7 alias `--dsw-menu-surface-fill`)
|
|
78
|
+
* `code` code blocks, banner included (`--dsw-alias-markdown-code-block`,
|
|
79
|
+
* `--dsw-alias-markdown-code-block-banner`)
|
|
79
80
|
* `input` input/control surfaces (`--dsw-specific-input-major`)
|
|
80
81
|
*/
|
|
81
82
|
const PART_OPACITY_KEYS = [
|
|
82
83
|
"bg",
|
|
83
84
|
"sidebar",
|
|
84
85
|
"card",
|
|
86
|
+
"code",
|
|
85
87
|
"input"
|
|
86
88
|
];
|
|
87
89
|
const DEFAULT_PART_OPACITIES = {
|
|
88
90
|
bg: .85,
|
|
89
91
|
sidebar: .93,
|
|
90
92
|
card: 1,
|
|
93
|
+
code: 1,
|
|
91
94
|
input: 1
|
|
92
95
|
};
|
|
93
96
|
/**
|
|
@@ -185,7 +188,9 @@ function hexToHsl(hex) {
|
|
|
185
188
|
*
|
|
186
189
|
* Holidays default to `fill` rather than a rule's `fit`: these are full-bleed
|
|
187
190
|
* festival wallpapers, and `fit` would letterbox them on every aspect ratio the
|
|
188
|
-
* art was not cut for.
|
|
191
|
+
* art was not cut for. The mode now lives on the IMAGE (see `BgImage.bgMode`), so
|
|
192
|
+
* that default is written there — and `fill` is what a holiday's read-back
|
|
193
|
+
* sanitizer refuses to lose (see `normalizeHolidayRule`).
|
|
189
194
|
*/
|
|
190
195
|
function defaultHolidayRule(def) {
|
|
191
196
|
const color = hexToHsl(def.color);
|
|
@@ -193,13 +198,13 @@ function defaultHolidayRule(def) {
|
|
|
193
198
|
id: def.id,
|
|
194
199
|
images: [{
|
|
195
200
|
slot: def.slot,
|
|
201
|
+
bgMode: "fill",
|
|
196
202
|
bgState: { ...DEFAULT_BG_STATE },
|
|
197
203
|
color
|
|
198
204
|
}],
|
|
199
205
|
match: "",
|
|
200
206
|
enabled: true,
|
|
201
207
|
color,
|
|
202
|
-
bgMode: "fill",
|
|
203
208
|
wallpaperOpacity: 1,
|
|
204
209
|
blur: 0,
|
|
205
210
|
rotate: defaultRotation()
|
|
@@ -308,12 +313,21 @@ function normalizeTransition(raw, legacyMs) {
|
|
|
308
313
|
* same as absent: an entry with `color: null` is an image the user explicitly set
|
|
309
314
|
* to "follow the system theme", and re-inheriting the rule's color over that
|
|
310
315
|
* would undo a deliberate choice on every load.
|
|
316
|
+
*
|
|
317
|
+
* `inheritMode` is the same one-shot lift for the layout mode, and it is the
|
|
318
|
+
* caller's to resolve because the two callers answer differently: a user rule
|
|
319
|
+
* passes the mode its config carried at the RULE level (a schema-5 config, where
|
|
320
|
+
* 适应/填充 was one choice for the whole rotation), a holiday passes `fill` (its
|
|
321
|
+
* own default, which is not up for the lift to change). An entry that HAS a
|
|
322
|
+
* `bgMode` key keeps it — including one that has just been set to a value equal
|
|
323
|
+
* to the default, which is a decision and not an absence.
|
|
311
324
|
*/
|
|
312
|
-
function normalizeImage(raw, inherit = null) {
|
|
325
|
+
function normalizeImage(raw, inherit = null, inheritMode = "fit") {
|
|
313
326
|
const i = raw ?? {};
|
|
314
327
|
if (typeof i.slot !== "string" || !SLOT_RE.test(i.slot)) return null;
|
|
315
328
|
return {
|
|
316
329
|
slot: i.slot,
|
|
330
|
+
bgMode: BG_MODES.includes(i.bgMode) ? i.bgMode : inheritMode,
|
|
317
331
|
bgState: normalizeBgState(i.bgState),
|
|
318
332
|
color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit
|
|
319
333
|
};
|
|
@@ -333,6 +347,14 @@ function normalizeImage(raw, inherit = null) {
|
|
|
333
347
|
* it as well: it is not a default for its images at paint time any more, but it
|
|
334
348
|
* is still what the rule paints once its last picture is gone.
|
|
335
349
|
*
|
|
350
|
+
* The layout mode makes the same trip in the other direction, and this is the
|
|
351
|
+
* last release that has to: a config written before 0.7.5 keeps `bgMode` at the RULE
|
|
352
|
+
* level, where one value covered every picture of a rotation, so it is read here
|
|
353
|
+
* once and handed to each image as its inherited mode. What the user chose
|
|
354
|
+
* therefore survives the move the same way it survived the color's — the mode is
|
|
355
|
+
* not reset to the default on upgrade, and the field it used to live on is simply
|
|
356
|
+
* not written back.
|
|
357
|
+
*
|
|
336
358
|
* An EMPTY list is a legitimate rule, not a broken one: removing a rule's last
|
|
337
359
|
* picture has to be expressible, and forcing an entry back in is what produced a
|
|
338
360
|
* phantom blank image that took slot 1 and could never be deleted again (the next
|
|
@@ -347,11 +369,12 @@ function normalizeRule(raw) {
|
|
|
347
369
|
const id = typeof r.id === "string" && r.id !== "" ? r.id : null;
|
|
348
370
|
if (id === null) return null;
|
|
349
371
|
const color = normalizeHsl(r.color);
|
|
372
|
+
const mode = BG_MODES.includes(r.bgMode) ? r.bgMode : "fit";
|
|
350
373
|
const images = [];
|
|
351
374
|
const seen = /* @__PURE__ */ new Set();
|
|
352
375
|
const hadList = Array.isArray(r.images);
|
|
353
376
|
if (hadList) for (const entry of r.images) {
|
|
354
|
-
const image = normalizeImage(entry, color);
|
|
377
|
+
const image = normalizeImage(entry, color, mode);
|
|
355
378
|
if (image === null || seen.has(image.slot)) continue;
|
|
356
379
|
seen.add(image.slot);
|
|
357
380
|
images.push(image);
|
|
@@ -359,21 +382,22 @@ function normalizeRule(raw) {
|
|
|
359
382
|
const legacySlot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
|
|
360
383
|
if (images.length === 0 && legacySlot !== null) images.push({
|
|
361
384
|
slot: legacySlot,
|
|
385
|
+
bgMode: mode,
|
|
362
386
|
bgState: normalizeBgState(r.bgState),
|
|
363
387
|
color
|
|
364
388
|
});
|
|
365
389
|
if (images.length === 0 && !hadList) return null;
|
|
366
|
-
const
|
|
390
|
+
const rotate = normalizeRotation(r.rotate);
|
|
391
|
+
if (images.length < 2) rotate.enabled = false;
|
|
367
392
|
return {
|
|
368
393
|
id,
|
|
369
394
|
images,
|
|
370
395
|
match: typeof r.match === "string" ? r.match : "",
|
|
371
396
|
enabled: r.enabled !== false,
|
|
372
397
|
color,
|
|
373
|
-
bgMode: mode,
|
|
374
398
|
wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
|
|
375
399
|
blur: clamp(r.blur, 0, 60, 0),
|
|
376
|
-
rotate
|
|
400
|
+
rotate
|
|
377
401
|
};
|
|
378
402
|
}
|
|
379
403
|
/**
|
|
@@ -384,9 +408,12 @@ function normalizeRule(raw) {
|
|
|
384
408
|
* its bytes, and its theme color is a fixed part of what that holiday looks like —
|
|
385
409
|
* so a stale or hand-edited value would either orphan the wallpaper, point the
|
|
386
410
|
* holiday at a rule's image, or paint a festival in a color it does not have.
|
|
387
|
-
* Everything else is sanitized exactly like a rule's field — except
|
|
388
|
-
*
|
|
389
|
-
*
|
|
411
|
+
* Everything else is sanitized exactly like a rule's field — except three things
|
|
412
|
+
* that stay the definition's own: the image list and the rotation (so a festival
|
|
413
|
+
* can never be swapped or cycled), and the layout mode, which is the definition's
|
|
414
|
+
* `fill` for the same reason the color is its own — full-bleed art letterboxed by
|
|
415
|
+
* a stale `fit` is the one thing this feature must not do, and the mode is not
|
|
416
|
+
* editable for a holiday anywhere in the panel.
|
|
390
417
|
*/
|
|
391
418
|
function normalizeHolidayRule(def, raw) {
|
|
392
419
|
const r = raw ?? {};
|
|
@@ -394,11 +421,11 @@ function normalizeHolidayRule(def, raw) {
|
|
|
394
421
|
return {
|
|
395
422
|
...base,
|
|
396
423
|
enabled: r.enabled !== false,
|
|
397
|
-
bgMode: BG_MODES.includes(r.bgMode) ? r.bgMode : base.bgMode,
|
|
398
424
|
wallpaperOpacity: clamp01(r.wallpaperOpacity, base.wallpaperOpacity),
|
|
399
425
|
blur: clamp(r.blur, 0, 60, base.blur),
|
|
400
426
|
images: base.images.map((image) => ({
|
|
401
427
|
slot: image.slot,
|
|
428
|
+
bgMode: image.bgMode,
|
|
402
429
|
bgState: normalizeBgState(r.images?.[0]?.bgState ?? r.bgState),
|
|
403
430
|
color: image.color
|
|
404
431
|
}))
|
|
@@ -452,6 +479,8 @@ function normalizeConfig(raw) {
|
|
|
452
479
|
* theme-config.json the ordered rule list + the global interface settings
|
|
453
480
|
* modelbg-<slot> one background image per rule (raw bytes, no extension;
|
|
454
481
|
* the MIME is sniffed from the magic bytes when served)
|
|
482
|
+
* holiday-cache/ festival art downloaded from the CDN, one file per
|
|
483
|
+
* holiday asset — deliberately NOT a slot (see below)
|
|
455
484
|
*
|
|
456
485
|
* Images travel as data URLs over the RPC channel — there is no separate HTTP
|
|
457
486
|
* image route. A legacy single-wallpaper / video-background store is migrated
|
|
@@ -474,10 +503,123 @@ const LEGACY_FILES = [
|
|
|
474
503
|
];
|
|
475
504
|
const WALLPAPER_FETCH_MAX = 26214400;
|
|
476
505
|
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
506
|
+
/**
|
|
507
|
+
* Where the festival art is hosted, and which revision of it this build wants.
|
|
508
|
+
*
|
|
509
|
+
* The two holiday wallpapers used to ship inside the package. They do not any
|
|
510
|
+
* more: they were 746 KB of the 1.1 MB tarball — two thirds of everything anyone
|
|
511
|
+
* downloaded — for art that is looked at on one day a year, and they are the one
|
|
512
|
+
* part of the package that is a picture rather than code. The bytes now live in
|
|
513
|
+
* the `assets` repository beside the README screenshots and are fetched from a
|
|
514
|
+
* CDN the first time a holiday actually needs one (see `readHolidayAsset`).
|
|
515
|
+
*
|
|
516
|
+
* The reference is a **commit**, never a branch. Runtime art has to be
|
|
517
|
+
* immutable: a branch reference would let a later reshuffle of the documentation
|
|
518
|
+
* screenshots silently kill a shipped feature, on a day nobody is watching, with
|
|
519
|
+
* nothing in the log — and jsDelivr caches a commit reference forever, which is
|
|
520
|
+
* exactly what a file that never changes wants. The cost is that new art means a
|
|
521
|
+
* new commit and a new hash here.
|
|
522
|
+
*/
|
|
523
|
+
const HOLIDAY_ASSETS_REPO = "HarmlessFunny/assets";
|
|
524
|
+
const HOLIDAY_ASSETS_COMMIT = "58a9e107ac9e1993035c494397dfe77bbb7e8720";
|
|
525
|
+
const HOLIDAY_ASSETS_DIR = "dsh-background-by-model/holiday";
|
|
526
|
+
/**
|
|
527
|
+
* Mirrors, tried in order. jsDelivr first because it is reachable from networks
|
|
528
|
+
* where `raw.githubusercontent.com` is not; raw second because a CDN can be
|
|
529
|
+
* blocked, rate-limited or down, and one dead mirror must not cost the easter
|
|
530
|
+
* egg. Every mirror serves the same commit, so a fallback cannot mix revisions.
|
|
531
|
+
*/
|
|
532
|
+
const HOLIDAY_ASSET_HOSTS = [`https://cdn.jsdelivr.net/gh/${HOLIDAY_ASSETS_REPO}@${HOLIDAY_ASSETS_COMMIT}/${HOLIDAY_ASSETS_DIR}`, `https://raw.githubusercontent.com/${HOLIDAY_ASSETS_REPO}/${HOLIDAY_ASSETS_COMMIT}/${HOLIDAY_ASSETS_DIR}`];
|
|
533
|
+
/**
|
|
534
|
+
* Sub-directory of the store holding downloaded festival art.
|
|
535
|
+
*
|
|
536
|
+
* It is NOT part of the slot namespace and must never become part of it: the
|
|
537
|
+
* cached files are named after the ASSET (`mid-autumn.webp`), not after a slot,
|
|
538
|
+
* so nothing here can ever be mistaken for a user's `modelbg-<slot>`. That is
|
|
539
|
+
* what lets a holiday slot stay read-only — `writeImage` still refuses it, and
|
|
540
|
+
* the store still never holds a `modelbg-h-*` file — while the art itself is
|
|
541
|
+
* now fetched rather than shipped.
|
|
542
|
+
*/
|
|
543
|
+
const HOLIDAY_CACHE_DIR = "holiday-cache";
|
|
544
|
+
/** Cap and timeout for one asset download; these files are 228 KB and 500 KB. */
|
|
545
|
+
const HOLIDAY_FETCH_MAX = 8388608;
|
|
546
|
+
const HOLIDAY_FETCH_TIMEOUT = 1e4;
|
|
547
|
+
/**
|
|
548
|
+
* The recommended profile, served from the same assets repository as the
|
|
549
|
+
* festival art — as a DIRECTORY, in the store's own shape.
|
|
550
|
+
*
|
|
551
|
+
* It used to be one theme file with every wallpaper inlined as base64: 2.9 MB
|
|
552
|
+
* where 99.8% of the bytes were a picture, so changing the `match` string of one
|
|
553
|
+
* rule meant finding it inside a single line of several hundred thousand
|
|
554
|
+
* characters. Now the published layout is exactly the layout of
|
|
555
|
+
* `~/.dsh/.dsh-background-by-model-data/` — a readable `theme-config.json` and
|
|
556
|
+
* one raw file per slot — which makes the configuration editable in place and
|
|
557
|
+
* makes publishing it a `cp` out of the data directory rather than a special
|
|
558
|
+
* export step. The bytes on the wire are the same; only their arrangement is
|
|
559
|
+
* different.
|
|
560
|
+
*
|
|
561
|
+
* Nothing lists the images: the request set is DERIVED from the config's own
|
|
562
|
+
* `rules[].images[].slot`, so adding a wallpaper is adding a rule entry and a
|
|
563
|
+
* file, with no second place to keep in sync.
|
|
564
|
+
*
|
|
565
|
+
* Referenced by BRANCH, unlike the festival art, and deliberately so. The reason
|
|
566
|
+
* that art is pinned to a commit does not apply here: its rationale is that a
|
|
567
|
+
* reshuffle of the assets repository would make a shipped feature disappear
|
|
568
|
+
* SILENTLY, on a day nobody is watching. A profile fails LOUDLY — the user
|
|
569
|
+
* clicks a button and gets an error naming what went missing — so the failure
|
|
570
|
+
* immutability buys protection from cannot happen. What branch referencing buys
|
|
571
|
+
* instead is the ability to fix a recommendation without a plugin release:
|
|
572
|
+
* pinning would mean editing one JSON file costs a new commit, a new hash in
|
|
573
|
+
* this file and a new version on npm, and every user on an older build would
|
|
574
|
+
* keep getting the old recommendation forever.
|
|
575
|
+
*
|
|
576
|
+
* The risk that IS real — a profile written for a config shape this build cannot
|
|
577
|
+
* read — is what `preset.json` exists for (see `fetchPresetConfig`).
|
|
578
|
+
*/
|
|
579
|
+
const PRESET_ASSETS_REPO = "HarmlessFunny/assets";
|
|
580
|
+
const PRESET_ASSETS_REF = "main";
|
|
581
|
+
const PRESET_ASSET_DIR = "dsh-background-by-model/preset";
|
|
582
|
+
/** The shape gate: `{"version": 6}` — see SCHEMA_VERSION in ./schema. */
|
|
583
|
+
const PRESET_MANIFEST = "preset.json";
|
|
584
|
+
const PRESET_CONFIG = "theme-config.json";
|
|
585
|
+
/** Images are named after the slot they fill, exactly as they are on disk. */
|
|
586
|
+
const PRESET_IMAGE_PREFIX = "modelbg-";
|
|
587
|
+
/** Mirrors, tried in order — same reason as the festival art's. */
|
|
588
|
+
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}`];
|
|
589
|
+
/**
|
|
590
|
+
* Caps for the profile, now per file rather than for one document.
|
|
591
|
+
*
|
|
592
|
+
* Three of them, because a profile is now a SET of transfers and each has a
|
|
593
|
+
* different way of going wrong. The manifest and the config are small by nature
|
|
594
|
+
* — a manifest is one field, a config is a few kilobytes of JSON — so their caps
|
|
595
|
+
* are what stops a mirror from answering with something enormous instead. The
|
|
596
|
+
* images are the payload, so their cap is about the content.
|
|
597
|
+
*
|
|
598
|
+
* The count is the one that matters: the request set is derived from the config,
|
|
599
|
+
* so a hostile or broken config could name a thousand slots and turn one button
|
|
600
|
+
* press into a thousand downloads. `SLOT_RE` bounds each name and
|
|
601
|
+
* `PRESET_IMAGE_COUNT_MAX` bounds how many names there can be. The running TOTAL
|
|
602
|
+
* of what those transfers add up to is bounded on the client instead, because
|
|
603
|
+
* these calls are independent and this side never sees more than one file.
|
|
604
|
+
*
|
|
605
|
+
* The timeout is set by measurement, not by symmetry with the festival art's
|
|
606
|
+
* 10 s. A 350 KB wallpaper takes a second or two over jsDelivr from a normal
|
|
607
|
+
* connection, and the mirror that exists for the case where jsDelivr does not
|
|
608
|
+
* answer is the slower one — so a tight budget would fail exactly in the
|
|
609
|
+
* situation it was written for. A minute tolerates a slow link and is still
|
|
610
|
+
* bounded; the user is watching a button that says it is downloading, which is
|
|
611
|
+
* the one place waiting is honest.
|
|
612
|
+
*/
|
|
613
|
+
const PRESET_MANIFEST_MAX = 65536;
|
|
614
|
+
const PRESET_CONFIG_MAX = 1048576;
|
|
615
|
+
const PRESET_IMAGE_MAX = 8388608;
|
|
616
|
+
const PRESET_IMAGE_COUNT_MAX = 64;
|
|
617
|
+
const PRESET_FETCH_TIMEOUT = 6e4;
|
|
477
618
|
const dataDir = () => dshHomePath(DATA_DIR);
|
|
478
619
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
479
620
|
const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
|
|
480
621
|
const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
|
|
622
|
+
const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, asset);
|
|
481
623
|
const exists = async (p) => {
|
|
482
624
|
try {
|
|
483
625
|
await access(p);
|
|
@@ -517,12 +659,12 @@ async function migrateLegacy(raw) {
|
|
|
517
659
|
id: "m1",
|
|
518
660
|
images: [{
|
|
519
661
|
slot: "m1",
|
|
662
|
+
bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
|
|
520
663
|
bgState: r.bgState ?? {}
|
|
521
664
|
}],
|
|
522
665
|
match: "",
|
|
523
666
|
enabled: true,
|
|
524
667
|
color: Array.isArray(r.color) ? r.color : null,
|
|
525
|
-
bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
|
|
526
668
|
wallpaperOpacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
|
|
527
669
|
blur: typeof r.blur === "number" ? r.blur : 0
|
|
528
670
|
}],
|
|
@@ -564,7 +706,11 @@ const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
|
564
706
|
"blur",
|
|
565
707
|
"bgState"
|
|
566
708
|
]);
|
|
567
|
-
const LEGACY_RULE_KEYS = /* @__PURE__ */ new Set([
|
|
709
|
+
const LEGACY_RULE_KEYS = /* @__PURE__ */ new Set([
|
|
710
|
+
"slot",
|
|
711
|
+
"bgState",
|
|
712
|
+
"bgMode"
|
|
713
|
+
]);
|
|
568
714
|
const LEGACY_NESTED_KEYS = /* @__PURE__ */ new Set(["rules[].rotate.fadeMs"]);
|
|
569
715
|
const warnedConfigKeys = /* @__PURE__ */ new Set();
|
|
570
716
|
function warnUnknownConfigKeys(raw, normalized) {
|
|
@@ -628,29 +774,53 @@ async function writeConfig(config) {
|
|
|
628
774
|
return false;
|
|
629
775
|
}
|
|
630
776
|
}
|
|
631
|
-
/**
|
|
632
|
-
|
|
777
|
+
/**
|
|
778
|
+
* Sniff an image's MIME from its leading magic bytes, or null when the bytes are
|
|
779
|
+
* not a recognised image at all.
|
|
780
|
+
*
|
|
781
|
+
* The null answer is what a DOWNLOAD is checked against (see `fetchHolidayAsset`):
|
|
782
|
+
* a CDN that answers 200 with an error page, a captive-portal redirect or a
|
|
783
|
+
* truncated body has to be rejected, and neither a status code nor a
|
|
784
|
+
* `content-type` header survives contact with a misconfigured mirror. The magic
|
|
785
|
+
* bytes of the payload are the only claim that cannot lie.
|
|
786
|
+
*/
|
|
787
|
+
function imageMimeOrNull(buf) {
|
|
633
788
|
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
|
|
634
789
|
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
|
|
635
790
|
if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
|
|
636
791
|
if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return "image/webp";
|
|
637
792
|
if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) return "image/bmp";
|
|
638
|
-
return
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Sniff an image's MIME from its leading magic bytes (defaults to JPEG).
|
|
797
|
+
*
|
|
798
|
+
* The default is deliberate for the bytes a USER uploaded: an unrecognised format
|
|
799
|
+
* is better served as a JPEG and left to the browser than refused outright — the
|
|
800
|
+
* browser decides, and it can sniff what this list does not know.
|
|
801
|
+
*/
|
|
802
|
+
function sniffImageMime(buf) {
|
|
803
|
+
return imageMimeOrNull(buf) ?? "image/jpeg";
|
|
639
804
|
}
|
|
640
805
|
/**
|
|
641
806
|
* Read one slot as a data URL, or null when it has nothing to serve.
|
|
642
807
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* lazily, so
|
|
647
|
-
*
|
|
808
|
+
* Two very different sources behind one answer. A holiday slot resolves to the
|
|
809
|
+
* festival art — downloaded on demand and cached, never shipped (see
|
|
810
|
+
* `readHolidayAsset`). Everything else is the user's own bytes on disk. Both are
|
|
811
|
+
* resolved lazily, so neither is read, fetched or even looked for until a
|
|
812
|
+
* wallpaper actually needs it.
|
|
813
|
+
*
|
|
814
|
+
* `null` is a legitimate answer for a holiday — offline, a blocked CDN, a cold
|
|
815
|
+
* cache — and the caller is built for it: `pickHoliday` treats "no bytes" as
|
|
816
|
+
* "this holiday does not take over" and the model rules keep painting. A
|
|
817
|
+
* festival that cannot be downloaded must never blank the interface.
|
|
648
818
|
*/
|
|
649
819
|
async function readImage(slot) {
|
|
650
|
-
const asset =
|
|
820
|
+
const asset = holidayAssetFor(slot);
|
|
651
821
|
if (asset !== null) {
|
|
652
|
-
const
|
|
653
|
-
return
|
|
822
|
+
const art = await readHolidayAsset(asset);
|
|
823
|
+
return art === null ? null : toDataUrl(art);
|
|
654
824
|
}
|
|
655
825
|
const own = await readFileOrNull(imagePath(slot));
|
|
656
826
|
return own === null ? null : toDataUrl(own);
|
|
@@ -665,24 +835,245 @@ async function readFileOrNull(path) {
|
|
|
665
835
|
function toDataUrl(buf) {
|
|
666
836
|
return `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
667
837
|
}
|
|
668
|
-
/**
|
|
669
|
-
|
|
670
|
-
|
|
838
|
+
/** Downloads in flight, so two read paths asking at once share one transfer. */
|
|
839
|
+
const holidayInFlight = /* @__PURE__ */ new Map();
|
|
840
|
+
/**
|
|
841
|
+
* One holiday's art: from the cache if it is there and sound, otherwise from the
|
|
842
|
+
* network.
|
|
843
|
+
*
|
|
844
|
+
* The cached copy is validated rather than trusted — a process killed mid-write
|
|
845
|
+
* (or a full disk) would otherwise leave a permanently truncated file that every
|
|
846
|
+
* later read happily serves, and the holiday would be broken forever with no way
|
|
847
|
+
* back short of deleting a file nobody knows about. A cache entry that is not an
|
|
848
|
+
* image is treated exactly like a miss and overwritten by the next download.
|
|
849
|
+
*
|
|
850
|
+
* The in-flight map matters because the two readers can race: the boot hydrate
|
|
851
|
+
* and the midnight rollover timer both ask for the same slot, and the timer
|
|
852
|
+
* fires precisely when a machine that was asleep all day is waking up. Without
|
|
853
|
+
* it that is two simultaneous half-megabyte transfers for one picture.
|
|
854
|
+
*/
|
|
855
|
+
async function readHolidayAsset(asset) {
|
|
856
|
+
const cached = await readFileOrNull(holidayCachePath(asset));
|
|
857
|
+
if (cached !== null && imageMimeOrNull(cached) !== null) return cached;
|
|
858
|
+
const running = holidayInFlight.get(asset);
|
|
859
|
+
if (running !== void 0) return await running;
|
|
860
|
+
const task = downloadHolidayAsset(asset).finally(() => {
|
|
861
|
+
holidayInFlight.delete(asset);
|
|
862
|
+
});
|
|
863
|
+
holidayInFlight.set(asset, task);
|
|
864
|
+
return await task;
|
|
865
|
+
}
|
|
866
|
+
/** Try every mirror in turn; the first usable answer is cached and returned. */
|
|
867
|
+
async function downloadHolidayAsset(asset) {
|
|
868
|
+
for (const host of HOLIDAY_ASSET_HOSTS) {
|
|
869
|
+
const buf = await fetchHolidayAsset(`${host}/${asset}`);
|
|
870
|
+
if (buf === null) continue;
|
|
871
|
+
await writeHolidayCache(asset, buf);
|
|
872
|
+
return buf;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* One asset over the wire, or null for every way it can fail.
|
|
878
|
+
*
|
|
879
|
+
* Never throws: a holiday that cannot be fetched is a holiday that does not paint
|
|
880
|
+
* today, which is a normal state and not an error path the caller should have to
|
|
881
|
+
* wrap. The `content-length` check is a courtesy — a mirror that announces a
|
|
882
|
+
* huge body never gets to send it — while the post-read size check is the one
|
|
883
|
+
* that actually holds.
|
|
884
|
+
*
|
|
885
|
+
* Deliberately says nothing about WHAT the bytes are: the festival art and the
|
|
886
|
+
* recommended profile are both "a file from the assets repository", and the one
|
|
887
|
+
* thing they do not share is how a payload is recognised (magic bytes vs JSON).
|
|
888
|
+
* That check belongs to the caller, which is the only half that knows what it
|
|
889
|
+
* asked for.
|
|
890
|
+
*/
|
|
891
|
+
async function fetchAsset(url, max, timeout) {
|
|
892
|
+
try {
|
|
893
|
+
const ctl = new AbortController();
|
|
894
|
+
const timer = setTimeout(() => ctl.abort(), timeout);
|
|
895
|
+
try {
|
|
896
|
+
const res = await fetch(url, {
|
|
897
|
+
redirect: "follow",
|
|
898
|
+
signal: ctl.signal
|
|
899
|
+
});
|
|
900
|
+
if (!res.ok) return null;
|
|
901
|
+
const announced = Number(res.headers.get("content-length") ?? "");
|
|
902
|
+
if (isFinite(announced) && announced > max) return null;
|
|
903
|
+
const arr = await res.arrayBuffer();
|
|
904
|
+
if (arr.byteLength === 0 || arr.byteLength > max) return null;
|
|
905
|
+
return Buffer.from(arr);
|
|
906
|
+
} finally {
|
|
907
|
+
clearTimeout(timer);
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** One festival picture, or null when no mirror served an image. */
|
|
914
|
+
async function fetchHolidayAsset(url) {
|
|
915
|
+
const buf = await fetchAsset(url, HOLIDAY_FETCH_MAX, HOLIDAY_FETCH_TIMEOUT);
|
|
916
|
+
return buf === null || imageMimeOrNull(buf) === null ? null : buf;
|
|
917
|
+
}
|
|
918
|
+
/** One file out of the profile directory, or null when no mirror served it. */
|
|
919
|
+
async function fetchPresetFile(name, max) {
|
|
920
|
+
for (const host of PRESET_ASSET_HOSTS) {
|
|
921
|
+
const buf = await fetchAsset(`${host}/${name}`, max, PRESET_FETCH_TIMEOUT);
|
|
922
|
+
if (buf !== null) return buf;
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The profile's shape gate and its configuration, in one answer.
|
|
928
|
+
*
|
|
929
|
+
* Two files, fetched in order, because they answer different questions. The
|
|
930
|
+
* manifest says what SHAPE the config is written for, and a config written for a
|
|
931
|
+
* shape this build cannot read is refused here — before anything is downloaded
|
|
932
|
+
* in earnest and long before anything is deleted. The config then says what the
|
|
933
|
+
* profile IS.
|
|
934
|
+
*
|
|
935
|
+
* The sanitizer runs on this side, and that is not merely tidiness: the list of
|
|
936
|
+
* images to download is DERIVED from `rules[].images[].slot`, so an unsanitized
|
|
937
|
+
* config would let a remote file choose what this process requests. After
|
|
938
|
+
* `normalizeConfig` every slot has passed `SLOT_RE` (see `normalizeImage`), and
|
|
939
|
+
* the only names left to reject are the holiday slots — which a config CAN name,
|
|
940
|
+
* legally, inside `holidays.items`, and which have no file at all.
|
|
941
|
+
*
|
|
942
|
+
* Holiday slots are dropped rather than refused. They are unwritable by design
|
|
943
|
+
* (`writeImage` refuses them), so a rule that points at one is already inert on
|
|
944
|
+
* the client; failing the whole profile over a slot nothing could ever fill
|
|
945
|
+
* would be a worse answer than ignoring it.
|
|
946
|
+
*
|
|
947
|
+
* Nothing is cached to disk, unlike the festival art. That cache exists because
|
|
948
|
+
* the art is fetched on a schedule without anyone asking and must work offline
|
|
949
|
+
* afterwards; this is a button whose entire point is to fetch what is there NOW.
|
|
950
|
+
*/
|
|
951
|
+
async function fetchPresetConfig() {
|
|
952
|
+
const manifestRaw = await fetchPresetFile(PRESET_MANIFEST, PRESET_MANIFEST_MAX);
|
|
953
|
+
if (manifestRaw === null) return {
|
|
954
|
+
ok: false,
|
|
955
|
+
error: "download failed"
|
|
956
|
+
};
|
|
957
|
+
let manifest;
|
|
958
|
+
try {
|
|
959
|
+
manifest = JSON.parse(manifestRaw.toString("utf8"));
|
|
960
|
+
} catch {
|
|
961
|
+
return {
|
|
962
|
+
ok: false,
|
|
963
|
+
error: "not a profile"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const declared = manifest?.version;
|
|
967
|
+
if (typeof declared !== "number" || !isFinite(declared)) return {
|
|
968
|
+
ok: false,
|
|
969
|
+
error: "not a profile"
|
|
970
|
+
};
|
|
971
|
+
if (declared > 6) return {
|
|
972
|
+
ok: false,
|
|
973
|
+
error: "newer version"
|
|
974
|
+
};
|
|
975
|
+
const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
|
|
976
|
+
if (configRaw === null) return {
|
|
977
|
+
ok: false,
|
|
978
|
+
error: "download failed"
|
|
979
|
+
};
|
|
980
|
+
let raw;
|
|
981
|
+
try {
|
|
982
|
+
raw = JSON.parse(configRaw.toString("utf8"));
|
|
983
|
+
} catch {
|
|
984
|
+
return {
|
|
985
|
+
ok: false,
|
|
986
|
+
error: "not a profile"
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
|
|
990
|
+
ok: false,
|
|
991
|
+
error: "not a profile"
|
|
992
|
+
};
|
|
993
|
+
const config = normalizeConfig(raw);
|
|
994
|
+
const slots = /* @__PURE__ */ new Set();
|
|
995
|
+
for (const rule of config.rules) for (const image of rule.images) {
|
|
996
|
+
if (holidayAssetFor(image.slot) !== null) continue;
|
|
997
|
+
slots.add(image.slot);
|
|
998
|
+
}
|
|
999
|
+
if (slots.size > PRESET_IMAGE_COUNT_MAX) return {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
error: "too many images"
|
|
1002
|
+
};
|
|
1003
|
+
return {
|
|
1004
|
+
ok: true,
|
|
1005
|
+
version: declared,
|
|
1006
|
+
config,
|
|
1007
|
+
slots: [...slots]
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* One of the profile's wallpapers, as a data URL ready to be written into a slot.
|
|
1012
|
+
*
|
|
1013
|
+
* The same two checks the festival art gets, for the same reason: the bytes must
|
|
1014
|
+
* actually be an image (`imageMimeOrNull`, never the `content-type` header — the
|
|
1015
|
+
* mirror serves extensionless files as `application/octet-stream`), and the slot
|
|
1016
|
+
* must be one this process is willing to write. The slot name is re-checked here
|
|
1017
|
+
* because this is a public RPC: the list this side hands out is not the only
|
|
1018
|
+
* thing a caller could ask for.
|
|
1019
|
+
*/
|
|
1020
|
+
async function fetchPresetImage(slot) {
|
|
1021
|
+
if (!SLOT_RE.test(slot) || holidayAssetFor(slot) !== null) return {
|
|
1022
|
+
ok: false,
|
|
1023
|
+
error: "bad slot"
|
|
1024
|
+
};
|
|
1025
|
+
const buf = await fetchPresetFile(`${PRESET_IMAGE_PREFIX}${slot}`, PRESET_IMAGE_MAX);
|
|
1026
|
+
if (buf === null) return {
|
|
1027
|
+
ok: false,
|
|
1028
|
+
error: "download failed"
|
|
1029
|
+
};
|
|
1030
|
+
const mime = imageMimeOrNull(buf);
|
|
1031
|
+
if (mime === null) return {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
error: "not an image"
|
|
1034
|
+
};
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
dataUrl: `data:${mime};base64,${buf.toString("base64")}`
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Cache one downloaded asset, atomically and best-effort.
|
|
1042
|
+
*
|
|
1043
|
+
* Written to a temp name and renamed into place, so a reader never sees a partial
|
|
1044
|
+
* file even if this process dies between the two calls. A failure to cache is
|
|
1045
|
+
* logged and otherwise ignored: the bytes are already in hand and the caller is
|
|
1046
|
+
* waiting for them — losing the cache costs a re-download next time, and failing
|
|
1047
|
+
* the read over it would cost the holiday.
|
|
1048
|
+
*/
|
|
1049
|
+
async function writeHolidayCache(asset, buf) {
|
|
1050
|
+
try {
|
|
1051
|
+
await ensureDir();
|
|
1052
|
+
await mkdir(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR), { recursive: true });
|
|
1053
|
+
const target = holidayCachePath(asset);
|
|
1054
|
+
const temp = `${target}.tmp`;
|
|
1055
|
+
await writeFile(temp, buf);
|
|
1056
|
+
await rename(temp, target);
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
console.warn(`dsh-background-by-model: could not cache the "${asset}" holiday art`, e);
|
|
1059
|
+
}
|
|
671
1060
|
}
|
|
672
1061
|
/**
|
|
673
|
-
* The wallpaper
|
|
1062
|
+
* The wallpaper a holiday slot resolves to, or null when the slot is not a
|
|
1063
|
+
* holiday's.
|
|
674
1064
|
*
|
|
675
|
-
* This mapping is the ONLY thing that makes a holiday slot read-only
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
1065
|
+
* This mapping is the ONLY thing that makes a holiday slot read-only, so both
|
|
1066
|
+
* `readImage` and `writeImage` go through it rather than each testing the holiday
|
|
1067
|
+
* list on their own. It reads no config: the festival art has stopped being a
|
|
1068
|
+
* fallback that a flag could disable. What changed is only WHERE the bytes come
|
|
1069
|
+
* from — the slot's identity, and its refusal to accept a write, are untouched.
|
|
679
1070
|
*/
|
|
680
|
-
function
|
|
1071
|
+
function holidayAssetFor(slot) {
|
|
681
1072
|
return HOLIDAYS.find((h) => h.slot === slot)?.asset ?? null;
|
|
682
1073
|
}
|
|
683
1074
|
/** Persist one rule image (null removes it); false keeps the previous file. */
|
|
684
1075
|
async function writeImage(slot, dataUrl) {
|
|
685
|
-
if (
|
|
1076
|
+
if (holidayAssetFor(slot) !== null) return false;
|
|
686
1077
|
await ensureDir();
|
|
687
1078
|
try {
|
|
688
1079
|
if (dataUrl === null) {
|
|
@@ -707,11 +1098,46 @@ async function listSlots() {
|
|
|
707
1098
|
}
|
|
708
1099
|
}
|
|
709
1100
|
/**
|
|
1101
|
+
* Empty the store: the config and every rule image, and nothing else.
|
|
1102
|
+
*
|
|
1103
|
+
* The hard reset behind "use the recommended profile", and it has to be a real
|
|
1104
|
+
* one rather than a config overwrite. `nextSlot` hands out `m1`, `m2`, … — a
|
|
1105
|
+
* running counter, not a random token — so a profile and a store that both have
|
|
1106
|
+
* pictures collide on those names as a matter of course. Emptying first is what
|
|
1107
|
+
* makes the profile's own slots free to write, and it is why this feature needs
|
|
1108
|
+
* no slot remapping: there is nothing left to collide with.
|
|
1109
|
+
*
|
|
1110
|
+
* `holiday-cache/` is deliberately untouched. It is not the user's
|
|
1111
|
+
* configuration: it is downloaded festival art that belongs to no slot, costs a
|
|
1112
|
+
* network round trip to get back, and has nothing to do with which profile is
|
|
1113
|
+
* selected.
|
|
1114
|
+
*
|
|
1115
|
+
* Best-effort per entry — one unremovable file must not abort the rest and leave
|
|
1116
|
+
* the store half wiped — but the failures are logged rather than swallowed,
|
|
1117
|
+
* because the caller is about to write a fresh config over whatever survives.
|
|
1118
|
+
*/
|
|
1119
|
+
async function resetStore() {
|
|
1120
|
+
let ok = true;
|
|
1121
|
+
try {
|
|
1122
|
+
await rm(configPath(), { force: true });
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
ok = false;
|
|
1125
|
+
console.warn(`dsh-background-by-model: failed to remove "${CONFIG_FILE}"`, e);
|
|
1126
|
+
}
|
|
1127
|
+
for (const slot of await listSlots()) try {
|
|
1128
|
+
await rm(imagePath(slot), { force: true });
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
ok = false;
|
|
1131
|
+
console.warn(`dsh-background-by-model: failed to remove the image for slot "${slot}"`, e);
|
|
1132
|
+
}
|
|
1133
|
+
return ok;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
710
1136
|
* Download an image from a network URL into one slot (replacing its bytes).
|
|
711
1137
|
* Returns { ok, dataUrl?, error? }; never throws.
|
|
712
1138
|
*/
|
|
713
1139
|
async function fetchImageUrl(slot, url) {
|
|
714
|
-
if (
|
|
1140
|
+
if (holidayAssetFor(slot) !== null) return {
|
|
715
1141
|
ok: false,
|
|
716
1142
|
error: "read-only slot"
|
|
717
1143
|
};
|
|
@@ -818,7 +1244,7 @@ async function handleRpcMethod(ctx, endpoint, payload) {
|
|
|
818
1244
|
value: {
|
|
819
1245
|
config: await readConfig(),
|
|
820
1246
|
slots: await listSlots(),
|
|
821
|
-
schema:
|
|
1247
|
+
schema: 6
|
|
822
1248
|
}
|
|
823
1249
|
};
|
|
824
1250
|
case "writeConfig": return {
|
|
@@ -864,6 +1290,26 @@ async function handleRpcMethod(ctx, endpoint, payload) {
|
|
|
864
1290
|
ok: true,
|
|
865
1291
|
value: await fetchImageUrl(slot, payload?.url ?? null)
|
|
866
1292
|
};
|
|
1293
|
+
case "fetchPresetConfig": return {
|
|
1294
|
+
ok: true,
|
|
1295
|
+
value: await fetchPresetConfig()
|
|
1296
|
+
};
|
|
1297
|
+
case "fetchPresetImage":
|
|
1298
|
+
if (slot === null) return {
|
|
1299
|
+
ok: true,
|
|
1300
|
+
value: {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
error: "bad slot"
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
return {
|
|
1306
|
+
ok: true,
|
|
1307
|
+
value: await fetchPresetImage(slot)
|
|
1308
|
+
};
|
|
1309
|
+
case "resetStore": return {
|
|
1310
|
+
ok: true,
|
|
1311
|
+
value: await resetStore()
|
|
1312
|
+
};
|
|
867
1313
|
case "defaultModel": return {
|
|
868
1314
|
ok: true,
|
|
869
1315
|
value: defaultModel(ctx)
|