dsh-background-by-model 0.7.5 → 0.7.7
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 +25 -1
- package/CHANGELOG.zh.md +25 -1
- package/README.md +58 -26
- package/README.zh.md +58 -26
- package/lib/client.js +1252 -559
- package/lib/index.js +472 -33
- package/package.json +4 -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 = [{
|
|
@@ -88,11 +87,11 @@ const PART_OPACITY_KEYS = [
|
|
|
88
87
|
"input"
|
|
89
88
|
];
|
|
90
89
|
const DEFAULT_PART_OPACITIES = {
|
|
91
|
-
bg:
|
|
92
|
-
sidebar: .
|
|
90
|
+
bg: 0,
|
|
91
|
+
sidebar: .1,
|
|
93
92
|
card: 1,
|
|
94
|
-
code:
|
|
95
|
-
input:
|
|
93
|
+
code: .81,
|
|
94
|
+
input: .88
|
|
96
95
|
};
|
|
97
96
|
/**
|
|
98
97
|
* Interface blur (px, 0..60), global (Interface page):
|
|
@@ -123,7 +122,7 @@ const DEFAULT_PART_BLURS = {
|
|
|
123
122
|
chat: 0,
|
|
124
123
|
trajectory: 0,
|
|
125
124
|
rightbar: 0,
|
|
126
|
-
input:
|
|
125
|
+
input: 60
|
|
127
126
|
};
|
|
128
127
|
/** Longest allowed switch duration; 0 is a hard cut. */
|
|
129
128
|
const TRANSITION_MAX_MS = 3e3;
|
|
@@ -147,6 +146,9 @@ const DEFAULT_TRANSITION = {
|
|
|
147
146
|
easing: "ease",
|
|
148
147
|
durationMs: 320
|
|
149
148
|
};
|
|
149
|
+
const DEFAULT_CHAT_TEXT_OPACITY = .35;
|
|
150
|
+
const DEFAULT_TRAJECTORY_OPACITY = .73;
|
|
151
|
+
const DEFAULT_RIGHTBAR_OPACITY = .45;
|
|
150
152
|
/**
|
|
151
153
|
* `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
|
|
152
154
|
* null when the string is not a hex color.
|
|
@@ -349,7 +351,7 @@ function normalizeImage(raw, inherit = null, inheritMode = "fit") {
|
|
|
349
351
|
* is still what the rule paints once its last picture is gone.
|
|
350
352
|
*
|
|
351
353
|
* The layout mode makes the same trip in the other direction, and this is the
|
|
352
|
-
* last release that has to: a config written before 0.
|
|
354
|
+
* last release that has to: a config written before 0.7.5 keeps `bgMode` at the RULE
|
|
353
355
|
* level, where one value covered every picture of a rotation, so it is read here
|
|
354
356
|
* once and handed to each image as its inherited mode. What the user chose
|
|
355
357
|
* therefore survives the move the same way it survived the color's — the mode is
|
|
@@ -441,10 +443,32 @@ function normalizeHolidays(raw) {
|
|
|
441
443
|
if (typeof id === "string") stored.set(id, item);
|
|
442
444
|
}
|
|
443
445
|
return {
|
|
444
|
-
enabled: r.enabled
|
|
446
|
+
enabled: typeof r.enabled === "boolean" ? r.enabled : false,
|
|
445
447
|
items: HOLIDAYS.map((def) => normalizeHolidayRule(def, stored.get(def.id)))
|
|
446
448
|
};
|
|
447
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* The file-preview panel's own opacity.
|
|
452
|
+
*
|
|
453
|
+
* Three states, and the middle one is why this is a function rather than the
|
|
454
|
+
* one-line fallback every other field gets: a real number is the card's own
|
|
455
|
+
* value, `null` is "never owned, so follow `opacities.bg`", and a KEY that is not
|
|
456
|
+
* there at all — a hand-written config, or one written before the option existed
|
|
457
|
+
* — is the shipped profile's 45% (see DEFAULT_RIGHTBAR_OPACITY).
|
|
458
|
+
*
|
|
459
|
+
* `null` has to survive as ITSELF instead of falling through to the default:
|
|
460
|
+
* every release so far has written it — a panel the user never dragged is
|
|
461
|
+
* persisted as an explicit `null` — so treating it as "missing" would hand the
|
|
462
|
+
* shipped 45% to every configuration whose preview panel was following the main
|
|
463
|
+
* background, a silent change to a setup nobody asked to change. It is also
|
|
464
|
+
* unreachable from the UI (no control returns to it); it exists for configs that
|
|
465
|
+
* were written while the shipped default was `null`.
|
|
466
|
+
*/
|
|
467
|
+
function normalizeRightbarOpacity(v) {
|
|
468
|
+
if (v === null) return null;
|
|
469
|
+
if (typeof v === "number" && isFinite(v)) return clamp01(v, 1);
|
|
470
|
+
return DEFAULT_RIGHTBAR_OPACITY;
|
|
471
|
+
}
|
|
448
472
|
/** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
|
|
449
473
|
function normalizeConfig(raw) {
|
|
450
474
|
const r = raw ?? {};
|
|
@@ -461,9 +485,9 @@ function normalizeConfig(raw) {
|
|
|
461
485
|
blurs,
|
|
462
486
|
transition: normalizeTransition(r.transition, legacyFadeMs(r.rules)),
|
|
463
487
|
settingsOpacity: clamp01(r.settingsOpacity, 1),
|
|
464
|
-
chatTextOpacity: clamp01(r.chatTextOpacity,
|
|
465
|
-
trajectoryOpacity: clamp01(r.trajectoryOpacity,
|
|
466
|
-
rightbarOpacity:
|
|
488
|
+
chatTextOpacity: clamp01(r.chatTextOpacity, DEFAULT_CHAT_TEXT_OPACITY),
|
|
489
|
+
trajectoryOpacity: clamp01(r.trajectoryOpacity, DEFAULT_TRAJECTORY_OPACITY),
|
|
490
|
+
rightbarOpacity: normalizeRightbarOpacity(r.rightbarOpacity),
|
|
467
491
|
autoExtract: r.autoExtract !== false,
|
|
468
492
|
holidays: normalizeHolidays(r.holidays)
|
|
469
493
|
};
|
|
@@ -480,6 +504,8 @@ function normalizeConfig(raw) {
|
|
|
480
504
|
* theme-config.json the ordered rule list + the global interface settings
|
|
481
505
|
* modelbg-<slot> one background image per rule (raw bytes, no extension;
|
|
482
506
|
* the MIME is sniffed from the magic bytes when served)
|
|
507
|
+
* holiday-cache/ festival art downloaded from the CDN, one file per
|
|
508
|
+
* holiday asset — deliberately NOT a slot (see below)
|
|
483
509
|
*
|
|
484
510
|
* Images travel as data URLs over the RPC channel — there is no separate HTTP
|
|
485
511
|
* image route. A legacy single-wallpaper / video-background store is migrated
|
|
@@ -502,10 +528,123 @@ const LEGACY_FILES = [
|
|
|
502
528
|
];
|
|
503
529
|
const WALLPAPER_FETCH_MAX = 26214400;
|
|
504
530
|
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
531
|
+
/**
|
|
532
|
+
* Where the festival art is hosted, and which revision of it this build wants.
|
|
533
|
+
*
|
|
534
|
+
* The two holiday wallpapers used to ship inside the package. They do not any
|
|
535
|
+
* more: they were 746 KB of the 1.1 MB tarball — two thirds of everything anyone
|
|
536
|
+
* downloaded — for art that is looked at on one day a year, and they are the one
|
|
537
|
+
* part of the package that is a picture rather than code. The bytes now live in
|
|
538
|
+
* the `assets` repository beside the README screenshots and are fetched from a
|
|
539
|
+
* CDN the first time a holiday actually needs one (see `readHolidayAsset`).
|
|
540
|
+
*
|
|
541
|
+
* The reference is a **commit**, never a branch. Runtime art has to be
|
|
542
|
+
* immutable: a branch reference would let a later reshuffle of the documentation
|
|
543
|
+
* screenshots silently kill a shipped feature, on a day nobody is watching, with
|
|
544
|
+
* nothing in the log — and jsDelivr caches a commit reference forever, which is
|
|
545
|
+
* exactly what a file that never changes wants. The cost is that new art means a
|
|
546
|
+
* new commit and a new hash here.
|
|
547
|
+
*/
|
|
548
|
+
const HOLIDAY_ASSETS_REPO = "HarmlessFunny/assets";
|
|
549
|
+
const HOLIDAY_ASSETS_COMMIT = "58a9e107ac9e1993035c494397dfe77bbb7e8720";
|
|
550
|
+
const HOLIDAY_ASSETS_DIR = "dsh-background-by-model/holiday";
|
|
551
|
+
/**
|
|
552
|
+
* Mirrors, tried in order. jsDelivr first because it is reachable from networks
|
|
553
|
+
* where `raw.githubusercontent.com` is not; raw second because a CDN can be
|
|
554
|
+
* blocked, rate-limited or down, and one dead mirror must not cost the easter
|
|
555
|
+
* egg. Every mirror serves the same commit, so a fallback cannot mix revisions.
|
|
556
|
+
*/
|
|
557
|
+
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}`];
|
|
558
|
+
/**
|
|
559
|
+
* Sub-directory of the store holding downloaded festival art.
|
|
560
|
+
*
|
|
561
|
+
* It is NOT part of the slot namespace and must never become part of it: the
|
|
562
|
+
* cached files are named after the ASSET (`mid-autumn.webp`), not after a slot,
|
|
563
|
+
* so nothing here can ever be mistaken for a user's `modelbg-<slot>`. That is
|
|
564
|
+
* what lets a holiday slot stay read-only — `writeImage` still refuses it, and
|
|
565
|
+
* the store still never holds a `modelbg-h-*` file — while the art itself is
|
|
566
|
+
* now fetched rather than shipped.
|
|
567
|
+
*/
|
|
568
|
+
const HOLIDAY_CACHE_DIR = "holiday-cache";
|
|
569
|
+
/** Cap and timeout for one asset download; these files are 228 KB and 500 KB. */
|
|
570
|
+
const HOLIDAY_FETCH_MAX = 8388608;
|
|
571
|
+
const HOLIDAY_FETCH_TIMEOUT = 1e4;
|
|
572
|
+
/**
|
|
573
|
+
* The recommended profile, served from the same assets repository as the
|
|
574
|
+
* festival art — as a DIRECTORY, in the store's own shape.
|
|
575
|
+
*
|
|
576
|
+
* It used to be one theme file with every wallpaper inlined as base64: 2.9 MB
|
|
577
|
+
* where 99.8% of the bytes were a picture, so changing the `match` string of one
|
|
578
|
+
* rule meant finding it inside a single line of several hundred thousand
|
|
579
|
+
* characters. Now the published layout is exactly the layout of
|
|
580
|
+
* `~/.dsh/.dsh-background-by-model-data/` — a readable `theme-config.json` and
|
|
581
|
+
* one raw file per slot — which makes the configuration editable in place and
|
|
582
|
+
* makes publishing it a `cp` out of the data directory rather than a special
|
|
583
|
+
* export step. The bytes on the wire are the same; only their arrangement is
|
|
584
|
+
* different.
|
|
585
|
+
*
|
|
586
|
+
* Nothing lists the images: the request set is DERIVED from the config's own
|
|
587
|
+
* `rules[].images[].slot`, so adding a wallpaper is adding a rule entry and a
|
|
588
|
+
* file, with no second place to keep in sync.
|
|
589
|
+
*
|
|
590
|
+
* Referenced by BRANCH, unlike the festival art, and deliberately so. The reason
|
|
591
|
+
* that art is pinned to a commit does not apply here: its rationale is that a
|
|
592
|
+
* reshuffle of the assets repository would make a shipped feature disappear
|
|
593
|
+
* SILENTLY, on a day nobody is watching. A profile fails LOUDLY — the user
|
|
594
|
+
* clicks a button and gets an error naming what went missing — so the failure
|
|
595
|
+
* immutability buys protection from cannot happen. What branch referencing buys
|
|
596
|
+
* instead is the ability to fix a recommendation without a plugin release:
|
|
597
|
+
* pinning would mean editing one JSON file costs a new commit, a new hash in
|
|
598
|
+
* this file and a new version on npm, and every user on an older build would
|
|
599
|
+
* keep getting the old recommendation forever.
|
|
600
|
+
*
|
|
601
|
+
* The risk that IS real — a profile written for a config shape this build cannot
|
|
602
|
+
* read — is what `preset.json` exists for (see `fetchPresetConfig`).
|
|
603
|
+
*/
|
|
604
|
+
const PRESET_ASSETS_REPO = "HarmlessFunny/assets";
|
|
605
|
+
const PRESET_ASSETS_REF = "main";
|
|
606
|
+
const PRESET_ASSET_DIR = "dsh-background-by-model/preset";
|
|
607
|
+
/** The shape gate: `{"version": 6}` — see SCHEMA_VERSION in ./schema. */
|
|
608
|
+
const PRESET_MANIFEST = "preset.json";
|
|
609
|
+
const PRESET_CONFIG = "theme-config.json";
|
|
610
|
+
/** Images are named after the slot they fill, exactly as they are on disk. */
|
|
611
|
+
const PRESET_IMAGE_PREFIX = "modelbg-";
|
|
612
|
+
/** Mirrors, tried in order — same reason as the festival art's. */
|
|
613
|
+
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}`];
|
|
614
|
+
/**
|
|
615
|
+
* Caps for the profile, now per file rather than for one document.
|
|
616
|
+
*
|
|
617
|
+
* Three of them, because a profile is now a SET of transfers and each has a
|
|
618
|
+
* different way of going wrong. The manifest and the config are small by nature
|
|
619
|
+
* — a manifest is one field, a config is a few kilobytes of JSON — so their caps
|
|
620
|
+
* are what stops a mirror from answering with something enormous instead. The
|
|
621
|
+
* images are the payload, so their cap is about the content.
|
|
622
|
+
*
|
|
623
|
+
* The count is the one that matters: the request set is derived from the config,
|
|
624
|
+
* so a hostile or broken config could name a thousand slots and turn one button
|
|
625
|
+
* press into a thousand downloads. `SLOT_RE` bounds each name and
|
|
626
|
+
* `PRESET_IMAGE_COUNT_MAX` bounds how many names there can be. The running TOTAL
|
|
627
|
+
* of what those transfers add up to is bounded on the client instead, because
|
|
628
|
+
* these calls are independent and this side never sees more than one file.
|
|
629
|
+
*
|
|
630
|
+
* The timeout is set by measurement, not by symmetry with the festival art's
|
|
631
|
+
* 10 s. A 350 KB wallpaper takes a second or two over jsDelivr from a normal
|
|
632
|
+
* connection, and the mirror that exists for the case where jsDelivr does not
|
|
633
|
+
* answer is the slower one — so a tight budget would fail exactly in the
|
|
634
|
+
* situation it was written for. A minute tolerates a slow link and is still
|
|
635
|
+
* bounded; the user is watching a button that says it is downloading, which is
|
|
636
|
+
* the one place waiting is honest.
|
|
637
|
+
*/
|
|
638
|
+
const PRESET_MANIFEST_MAX = 65536;
|
|
639
|
+
const PRESET_CONFIG_MAX = 1048576;
|
|
640
|
+
const PRESET_IMAGE_MAX = 8388608;
|
|
641
|
+
const PRESET_IMAGE_COUNT_MAX = 64;
|
|
642
|
+
const PRESET_FETCH_TIMEOUT = 6e4;
|
|
505
643
|
const dataDir = () => dshHomePath(DATA_DIR);
|
|
506
644
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
507
645
|
const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
|
|
508
646
|
const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
|
|
647
|
+
const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, asset);
|
|
509
648
|
const exists = async (p) => {
|
|
510
649
|
try {
|
|
511
650
|
await access(p);
|
|
@@ -660,29 +799,53 @@ async function writeConfig(config) {
|
|
|
660
799
|
return false;
|
|
661
800
|
}
|
|
662
801
|
}
|
|
663
|
-
/**
|
|
664
|
-
|
|
802
|
+
/**
|
|
803
|
+
* Sniff an image's MIME from its leading magic bytes, or null when the bytes are
|
|
804
|
+
* not a recognised image at all.
|
|
805
|
+
*
|
|
806
|
+
* The null answer is what a DOWNLOAD is checked against (see `fetchHolidayAsset`):
|
|
807
|
+
* a CDN that answers 200 with an error page, a captive-portal redirect or a
|
|
808
|
+
* truncated body has to be rejected, and neither a status code nor a
|
|
809
|
+
* `content-type` header survives contact with a misconfigured mirror. The magic
|
|
810
|
+
* bytes of the payload are the only claim that cannot lie.
|
|
811
|
+
*/
|
|
812
|
+
function imageMimeOrNull(buf) {
|
|
665
813
|
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
|
|
666
814
|
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
|
|
667
815
|
if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
|
|
668
816
|
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";
|
|
669
817
|
if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) return "image/bmp";
|
|
670
|
-
return
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Sniff an image's MIME from its leading magic bytes (defaults to JPEG).
|
|
822
|
+
*
|
|
823
|
+
* The default is deliberate for the bytes a USER uploaded: an unrecognised format
|
|
824
|
+
* is better served as a JPEG and left to the browser than refused outright — the
|
|
825
|
+
* browser decides, and it can sniff what this list does not know.
|
|
826
|
+
*/
|
|
827
|
+
function sniffImageMime(buf) {
|
|
828
|
+
return imageMimeOrNull(buf) ?? "image/jpeg";
|
|
671
829
|
}
|
|
672
830
|
/**
|
|
673
831
|
* Read one slot as a data URL, or null when it has nothing to serve.
|
|
674
832
|
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
* lazily, so
|
|
679
|
-
*
|
|
833
|
+
* Two very different sources behind one answer. A holiday slot resolves to the
|
|
834
|
+
* festival art — downloaded on demand and cached, never shipped (see
|
|
835
|
+
* `readHolidayAsset`). Everything else is the user's own bytes on disk. Both are
|
|
836
|
+
* resolved lazily, so neither is read, fetched or even looked for until a
|
|
837
|
+
* wallpaper actually needs it.
|
|
838
|
+
*
|
|
839
|
+
* `null` is a legitimate answer for a holiday — offline, a blocked CDN, a cold
|
|
840
|
+
* cache — and the caller is built for it: `pickHoliday` treats "no bytes" as
|
|
841
|
+
* "this holiday does not take over" and the model rules keep painting. A
|
|
842
|
+
* festival that cannot be downloaded must never blank the interface.
|
|
680
843
|
*/
|
|
681
844
|
async function readImage(slot) {
|
|
682
|
-
const asset =
|
|
845
|
+
const asset = holidayAssetFor(slot);
|
|
683
846
|
if (asset !== null) {
|
|
684
|
-
const
|
|
685
|
-
return
|
|
847
|
+
const art = await readHolidayAsset(asset);
|
|
848
|
+
return art === null ? null : toDataUrl(art);
|
|
686
849
|
}
|
|
687
850
|
const own = await readFileOrNull(imagePath(slot));
|
|
688
851
|
return own === null ? null : toDataUrl(own);
|
|
@@ -697,24 +860,245 @@ async function readFileOrNull(path) {
|
|
|
697
860
|
function toDataUrl(buf) {
|
|
698
861
|
return `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
699
862
|
}
|
|
700
|
-
/**
|
|
701
|
-
|
|
702
|
-
|
|
863
|
+
/** Downloads in flight, so two read paths asking at once share one transfer. */
|
|
864
|
+
const holidayInFlight = /* @__PURE__ */ new Map();
|
|
865
|
+
/**
|
|
866
|
+
* One holiday's art: from the cache if it is there and sound, otherwise from the
|
|
867
|
+
* network.
|
|
868
|
+
*
|
|
869
|
+
* The cached copy is validated rather than trusted — a process killed mid-write
|
|
870
|
+
* (or a full disk) would otherwise leave a permanently truncated file that every
|
|
871
|
+
* later read happily serves, and the holiday would be broken forever with no way
|
|
872
|
+
* back short of deleting a file nobody knows about. A cache entry that is not an
|
|
873
|
+
* image is treated exactly like a miss and overwritten by the next download.
|
|
874
|
+
*
|
|
875
|
+
* The in-flight map matters because the two readers can race: the boot hydrate
|
|
876
|
+
* and the midnight rollover timer both ask for the same slot, and the timer
|
|
877
|
+
* fires precisely when a machine that was asleep all day is waking up. Without
|
|
878
|
+
* it that is two simultaneous half-megabyte transfers for one picture.
|
|
879
|
+
*/
|
|
880
|
+
async function readHolidayAsset(asset) {
|
|
881
|
+
const cached = await readFileOrNull(holidayCachePath(asset));
|
|
882
|
+
if (cached !== null && imageMimeOrNull(cached) !== null) return cached;
|
|
883
|
+
const running = holidayInFlight.get(asset);
|
|
884
|
+
if (running !== void 0) return await running;
|
|
885
|
+
const task = downloadHolidayAsset(asset).finally(() => {
|
|
886
|
+
holidayInFlight.delete(asset);
|
|
887
|
+
});
|
|
888
|
+
holidayInFlight.set(asset, task);
|
|
889
|
+
return await task;
|
|
890
|
+
}
|
|
891
|
+
/** Try every mirror in turn; the first usable answer is cached and returned. */
|
|
892
|
+
async function downloadHolidayAsset(asset) {
|
|
893
|
+
for (const host of HOLIDAY_ASSET_HOSTS) {
|
|
894
|
+
const buf = await fetchHolidayAsset(`${host}/${asset}`);
|
|
895
|
+
if (buf === null) continue;
|
|
896
|
+
await writeHolidayCache(asset, buf);
|
|
897
|
+
return buf;
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* One asset over the wire, or null for every way it can fail.
|
|
903
|
+
*
|
|
904
|
+
* Never throws: a holiday that cannot be fetched is a holiday that does not paint
|
|
905
|
+
* today, which is a normal state and not an error path the caller should have to
|
|
906
|
+
* wrap. The `content-length` check is a courtesy — a mirror that announces a
|
|
907
|
+
* huge body never gets to send it — while the post-read size check is the one
|
|
908
|
+
* that actually holds.
|
|
909
|
+
*
|
|
910
|
+
* Deliberately says nothing about WHAT the bytes are: the festival art and the
|
|
911
|
+
* recommended profile are both "a file from the assets repository", and the one
|
|
912
|
+
* thing they do not share is how a payload is recognised (magic bytes vs JSON).
|
|
913
|
+
* That check belongs to the caller, which is the only half that knows what it
|
|
914
|
+
* asked for.
|
|
915
|
+
*/
|
|
916
|
+
async function fetchAsset(url, max, timeout) {
|
|
917
|
+
try {
|
|
918
|
+
const ctl = new AbortController();
|
|
919
|
+
const timer = setTimeout(() => ctl.abort(), timeout);
|
|
920
|
+
try {
|
|
921
|
+
const res = await fetch(url, {
|
|
922
|
+
redirect: "follow",
|
|
923
|
+
signal: ctl.signal
|
|
924
|
+
});
|
|
925
|
+
if (!res.ok) return null;
|
|
926
|
+
const announced = Number(res.headers.get("content-length") ?? "");
|
|
927
|
+
if (isFinite(announced) && announced > max) return null;
|
|
928
|
+
const arr = await res.arrayBuffer();
|
|
929
|
+
if (arr.byteLength === 0 || arr.byteLength > max) return null;
|
|
930
|
+
return Buffer.from(arr);
|
|
931
|
+
} finally {
|
|
932
|
+
clearTimeout(timer);
|
|
933
|
+
}
|
|
934
|
+
} catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/** One festival picture, or null when no mirror served an image. */
|
|
939
|
+
async function fetchHolidayAsset(url) {
|
|
940
|
+
const buf = await fetchAsset(url, HOLIDAY_FETCH_MAX, HOLIDAY_FETCH_TIMEOUT);
|
|
941
|
+
return buf === null || imageMimeOrNull(buf) === null ? null : buf;
|
|
942
|
+
}
|
|
943
|
+
/** One file out of the profile directory, or null when no mirror served it. */
|
|
944
|
+
async function fetchPresetFile(name, max) {
|
|
945
|
+
for (const host of PRESET_ASSET_HOSTS) {
|
|
946
|
+
const buf = await fetchAsset(`${host}/${name}`, max, PRESET_FETCH_TIMEOUT);
|
|
947
|
+
if (buf !== null) return buf;
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* The profile's shape gate and its configuration, in one answer.
|
|
953
|
+
*
|
|
954
|
+
* Two files, fetched in order, because they answer different questions. The
|
|
955
|
+
* manifest says what SHAPE the config is written for, and a config written for a
|
|
956
|
+
* shape this build cannot read is refused here — before anything is downloaded
|
|
957
|
+
* in earnest and long before anything is deleted. The config then says what the
|
|
958
|
+
* profile IS.
|
|
959
|
+
*
|
|
960
|
+
* The sanitizer runs on this side, and that is not merely tidiness: the list of
|
|
961
|
+
* images to download is DERIVED from `rules[].images[].slot`, so an unsanitized
|
|
962
|
+
* config would let a remote file choose what this process requests. After
|
|
963
|
+
* `normalizeConfig` every slot has passed `SLOT_RE` (see `normalizeImage`), and
|
|
964
|
+
* the only names left to reject are the holiday slots — which a config CAN name,
|
|
965
|
+
* legally, inside `holidays.items`, and which have no file at all.
|
|
966
|
+
*
|
|
967
|
+
* Holiday slots are dropped rather than refused. They are unwritable by design
|
|
968
|
+
* (`writeImage` refuses them), so a rule that points at one is already inert on
|
|
969
|
+
* the client; failing the whole profile over a slot nothing could ever fill
|
|
970
|
+
* would be a worse answer than ignoring it.
|
|
971
|
+
*
|
|
972
|
+
* Nothing is cached to disk, unlike the festival art. That cache exists because
|
|
973
|
+
* the art is fetched on a schedule without anyone asking and must work offline
|
|
974
|
+
* afterwards; this is a button whose entire point is to fetch what is there NOW.
|
|
975
|
+
*/
|
|
976
|
+
async function fetchPresetConfig() {
|
|
977
|
+
const manifestRaw = await fetchPresetFile(PRESET_MANIFEST, PRESET_MANIFEST_MAX);
|
|
978
|
+
if (manifestRaw === null) return {
|
|
979
|
+
ok: false,
|
|
980
|
+
error: "download failed"
|
|
981
|
+
};
|
|
982
|
+
let manifest;
|
|
983
|
+
try {
|
|
984
|
+
manifest = JSON.parse(manifestRaw.toString("utf8"));
|
|
985
|
+
} catch {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
error: "not a profile"
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const declared = manifest?.version;
|
|
992
|
+
if (typeof declared !== "number" || !isFinite(declared)) return {
|
|
993
|
+
ok: false,
|
|
994
|
+
error: "not a profile"
|
|
995
|
+
};
|
|
996
|
+
if (declared > 6) return {
|
|
997
|
+
ok: false,
|
|
998
|
+
error: "newer version"
|
|
999
|
+
};
|
|
1000
|
+
const configRaw = await fetchPresetFile(PRESET_CONFIG, PRESET_CONFIG_MAX);
|
|
1001
|
+
if (configRaw === null) return {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
error: "download failed"
|
|
1004
|
+
};
|
|
1005
|
+
let raw;
|
|
1006
|
+
try {
|
|
1007
|
+
raw = JSON.parse(configRaw.toString("utf8"));
|
|
1008
|
+
} catch {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
error: "not a profile"
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
error: "not a profile"
|
|
1017
|
+
};
|
|
1018
|
+
const config = normalizeConfig(raw);
|
|
1019
|
+
const slots = /* @__PURE__ */ new Set();
|
|
1020
|
+
for (const rule of config.rules) for (const image of rule.images) {
|
|
1021
|
+
if (holidayAssetFor(image.slot) !== null) continue;
|
|
1022
|
+
slots.add(image.slot);
|
|
1023
|
+
}
|
|
1024
|
+
if (slots.size > PRESET_IMAGE_COUNT_MAX) return {
|
|
1025
|
+
ok: false,
|
|
1026
|
+
error: "too many images"
|
|
1027
|
+
};
|
|
1028
|
+
return {
|
|
1029
|
+
ok: true,
|
|
1030
|
+
version: declared,
|
|
1031
|
+
config,
|
|
1032
|
+
slots: [...slots]
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* One of the profile's wallpapers, as a data URL ready to be written into a slot.
|
|
1037
|
+
*
|
|
1038
|
+
* The same two checks the festival art gets, for the same reason: the bytes must
|
|
1039
|
+
* actually be an image (`imageMimeOrNull`, never the `content-type` header — the
|
|
1040
|
+
* mirror serves extensionless files as `application/octet-stream`), and the slot
|
|
1041
|
+
* must be one this process is willing to write. The slot name is re-checked here
|
|
1042
|
+
* because this is a public RPC: the list this side hands out is not the only
|
|
1043
|
+
* thing a caller could ask for.
|
|
1044
|
+
*/
|
|
1045
|
+
async function fetchPresetImage(slot) {
|
|
1046
|
+
if (!SLOT_RE.test(slot) || holidayAssetFor(slot) !== null) return {
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: "bad slot"
|
|
1049
|
+
};
|
|
1050
|
+
const buf = await fetchPresetFile(`${PRESET_IMAGE_PREFIX}${slot}`, PRESET_IMAGE_MAX);
|
|
1051
|
+
if (buf === null) return {
|
|
1052
|
+
ok: false,
|
|
1053
|
+
error: "download failed"
|
|
1054
|
+
};
|
|
1055
|
+
const mime = imageMimeOrNull(buf);
|
|
1056
|
+
if (mime === null) return {
|
|
1057
|
+
ok: false,
|
|
1058
|
+
error: "not an image"
|
|
1059
|
+
};
|
|
1060
|
+
return {
|
|
1061
|
+
ok: true,
|
|
1062
|
+
dataUrl: `data:${mime};base64,${buf.toString("base64")}`
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Cache one downloaded asset, atomically and best-effort.
|
|
1067
|
+
*
|
|
1068
|
+
* Written to a temp name and renamed into place, so a reader never sees a partial
|
|
1069
|
+
* file even if this process dies between the two calls. A failure to cache is
|
|
1070
|
+
* logged and otherwise ignored: the bytes are already in hand and the caller is
|
|
1071
|
+
* waiting for them — losing the cache costs a re-download next time, and failing
|
|
1072
|
+
* the read over it would cost the holiday.
|
|
1073
|
+
*/
|
|
1074
|
+
async function writeHolidayCache(asset, buf) {
|
|
1075
|
+
try {
|
|
1076
|
+
await ensureDir();
|
|
1077
|
+
await mkdir(dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR), { recursive: true });
|
|
1078
|
+
const target = holidayCachePath(asset);
|
|
1079
|
+
const temp = `${target}.tmp`;
|
|
1080
|
+
await writeFile(temp, buf);
|
|
1081
|
+
await rename(temp, target);
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
console.warn(`dsh-background-by-model: could not cache the "${asset}" holiday art`, e);
|
|
1084
|
+
}
|
|
703
1085
|
}
|
|
704
1086
|
/**
|
|
705
|
-
* The wallpaper
|
|
1087
|
+
* The wallpaper a holiday slot resolves to, or null when the slot is not a
|
|
1088
|
+
* holiday's.
|
|
706
1089
|
*
|
|
707
|
-
* This mapping is the ONLY thing that makes a holiday slot read-only
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
1090
|
+
* This mapping is the ONLY thing that makes a holiday slot read-only, so both
|
|
1091
|
+
* `readImage` and `writeImage` go through it rather than each testing the holiday
|
|
1092
|
+
* list on their own. It reads no config: the festival art has stopped being a
|
|
1093
|
+
* fallback that a flag could disable. What changed is only WHERE the bytes come
|
|
1094
|
+
* from — the slot's identity, and its refusal to accept a write, are untouched.
|
|
711
1095
|
*/
|
|
712
|
-
function
|
|
1096
|
+
function holidayAssetFor(slot) {
|
|
713
1097
|
return HOLIDAYS.find((h) => h.slot === slot)?.asset ?? null;
|
|
714
1098
|
}
|
|
715
1099
|
/** Persist one rule image (null removes it); false keeps the previous file. */
|
|
716
1100
|
async function writeImage(slot, dataUrl) {
|
|
717
|
-
if (
|
|
1101
|
+
if (holidayAssetFor(slot) !== null) return false;
|
|
718
1102
|
await ensureDir();
|
|
719
1103
|
try {
|
|
720
1104
|
if (dataUrl === null) {
|
|
@@ -739,11 +1123,46 @@ async function listSlots() {
|
|
|
739
1123
|
}
|
|
740
1124
|
}
|
|
741
1125
|
/**
|
|
1126
|
+
* Empty the store: the config and every rule image, and nothing else.
|
|
1127
|
+
*
|
|
1128
|
+
* The hard reset behind "use the recommended profile", and it has to be a real
|
|
1129
|
+
* one rather than a config overwrite. `nextSlot` hands out `m1`, `m2`, … — a
|
|
1130
|
+
* running counter, not a random token — so a profile and a store that both have
|
|
1131
|
+
* pictures collide on those names as a matter of course. Emptying first is what
|
|
1132
|
+
* makes the profile's own slots free to write, and it is why this feature needs
|
|
1133
|
+
* no slot remapping: there is nothing left to collide with.
|
|
1134
|
+
*
|
|
1135
|
+
* `holiday-cache/` is deliberately untouched. It is not the user's
|
|
1136
|
+
* configuration: it is downloaded festival art that belongs to no slot, costs a
|
|
1137
|
+
* network round trip to get back, and has nothing to do with which profile is
|
|
1138
|
+
* selected.
|
|
1139
|
+
*
|
|
1140
|
+
* Best-effort per entry — one unremovable file must not abort the rest and leave
|
|
1141
|
+
* the store half wiped — but the failures are logged rather than swallowed,
|
|
1142
|
+
* because the caller is about to write a fresh config over whatever survives.
|
|
1143
|
+
*/
|
|
1144
|
+
async function resetStore() {
|
|
1145
|
+
let ok = true;
|
|
1146
|
+
try {
|
|
1147
|
+
await rm(configPath(), { force: true });
|
|
1148
|
+
} catch (e) {
|
|
1149
|
+
ok = false;
|
|
1150
|
+
console.warn(`dsh-background-by-model: failed to remove "${CONFIG_FILE}"`, e);
|
|
1151
|
+
}
|
|
1152
|
+
for (const slot of await listSlots()) try {
|
|
1153
|
+
await rm(imagePath(slot), { force: true });
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
ok = false;
|
|
1156
|
+
console.warn(`dsh-background-by-model: failed to remove the image for slot "${slot}"`, e);
|
|
1157
|
+
}
|
|
1158
|
+
return ok;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
742
1161
|
* Download an image from a network URL into one slot (replacing its bytes).
|
|
743
1162
|
* Returns { ok, dataUrl?, error? }; never throws.
|
|
744
1163
|
*/
|
|
745
1164
|
async function fetchImageUrl(slot, url) {
|
|
746
|
-
if (
|
|
1165
|
+
if (holidayAssetFor(slot) !== null) return {
|
|
747
1166
|
ok: false,
|
|
748
1167
|
error: "read-only slot"
|
|
749
1168
|
};
|
|
@@ -896,6 +1315,26 @@ async function handleRpcMethod(ctx, endpoint, payload) {
|
|
|
896
1315
|
ok: true,
|
|
897
1316
|
value: await fetchImageUrl(slot, payload?.url ?? null)
|
|
898
1317
|
};
|
|
1318
|
+
case "fetchPresetConfig": return {
|
|
1319
|
+
ok: true,
|
|
1320
|
+
value: await fetchPresetConfig()
|
|
1321
|
+
};
|
|
1322
|
+
case "fetchPresetImage":
|
|
1323
|
+
if (slot === null) return {
|
|
1324
|
+
ok: true,
|
|
1325
|
+
value: {
|
|
1326
|
+
ok: false,
|
|
1327
|
+
error: "bad slot"
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
return {
|
|
1331
|
+
ok: true,
|
|
1332
|
+
value: await fetchPresetImage(slot)
|
|
1333
|
+
};
|
|
1334
|
+
case "resetStore": return {
|
|
1335
|
+
ok: true,
|
|
1336
|
+
value: await resetStore()
|
|
1337
|
+
};
|
|
899
1338
|
case "defaultModel": return {
|
|
900
1339
|
ok: true,
|
|
901
1340
|
value: defaultModel(ctx)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-background-by-model",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "Appearance plugin for DeepSeek Harness: an ordered list of model rules, each carrying its own wallpapers — every image with its own framing and theme color — plus layout mode, opacity and blur; the background follows the model you switch to, and a rule can rotate through several images on its own rhythm.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -45,12 +45,9 @@
|
|
|
45
45
|
"lib/index.js",
|
|
46
46
|
"lib/invariant.js",
|
|
47
47
|
"lib/client.js",
|
|
48
|
-
"lib/client.js.map",
|
|
49
48
|
"CHANGELOG.md",
|
|
50
49
|
"CHANGELOG.zh.md",
|
|
51
|
-
"cordis.patch.yml"
|
|
52
|
-
"example_img",
|
|
53
|
-
"holiday"
|
|
50
|
+
"cordis.patch.yml"
|
|
54
51
|
],
|
|
55
52
|
"scripts": {
|
|
56
53
|
"bundle": "tsdown",
|
|
@@ -62,9 +59,10 @@
|
|
|
62
59
|
"check:repaint": "node scripts/repaint-check.ts",
|
|
63
60
|
"check:code-plate": "node scripts/code-plate-check.ts",
|
|
64
61
|
"check:image-mode": "node scripts/image-mode-check.ts",
|
|
62
|
+
"check:rules": "node scripts/rules-view-check.ts",
|
|
65
63
|
"check:ui": "node scripts/ui-strings-check.ts",
|
|
66
64
|
"check:node": "node scripts/node-half-check.mjs",
|
|
67
|
-
"test": "tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/code-plate-check.ts && node scripts/image-mode-check.ts && node scripts/ui-strings-check.ts && node scripts/node-half-check.mjs"
|
|
65
|
+
"test": "tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/code-plate-check.ts && node scripts/image-mode-check.ts && node scripts/rules-view-check.ts && node scripts/ui-strings-check.ts && node scripts/node-half-check.mjs"
|
|
68
66
|
},
|
|
69
67
|
"devDependencies": {
|
|
70
68
|
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/holiday/mid-autumn.webp
DELETED
|
Binary file
|
|
Binary file
|