dsh-background-by-model 0.7.5 → 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 +13 -1
- package/CHANGELOG.zh.md +13 -1
- package/README.md +49 -19
- package/README.zh.md +49 -19
- package/lib/client.js +1177 -501
- package/lib/index.js +438 -24
- 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 = [{
|
|
@@ -349,7 +348,7 @@ function normalizeImage(raw, inherit = null, inheritMode = "fit") {
|
|
|
349
348
|
* is still what the rule paints once its last picture is gone.
|
|
350
349
|
*
|
|
351
350
|
* 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.
|
|
351
|
+
* last release that has to: a config written before 0.7.5 keeps `bgMode` at the RULE
|
|
353
352
|
* level, where one value covered every picture of a rotation, so it is read here
|
|
354
353
|
* once and handed to each image as its inherited mode. What the user chose
|
|
355
354
|
* therefore survives the move the same way it survived the color's — the mode is
|
|
@@ -480,6 +479,8 @@ function normalizeConfig(raw) {
|
|
|
480
479
|
* theme-config.json the ordered rule list + the global interface settings
|
|
481
480
|
* modelbg-<slot> one background image per rule (raw bytes, no extension;
|
|
482
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)
|
|
483
484
|
*
|
|
484
485
|
* Images travel as data URLs over the RPC channel — there is no separate HTTP
|
|
485
486
|
* image route. A legacy single-wallpaper / video-background store is migrated
|
|
@@ -502,10 +503,123 @@ const LEGACY_FILES = [
|
|
|
502
503
|
];
|
|
503
504
|
const WALLPAPER_FETCH_MAX = 26214400;
|
|
504
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;
|
|
505
618
|
const dataDir = () => dshHomePath(DATA_DIR);
|
|
506
619
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
507
620
|
const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
|
|
508
621
|
const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
|
|
622
|
+
const holidayCachePath = (asset) => dshHomePath(DATA_DIR, HOLIDAY_CACHE_DIR, asset);
|
|
509
623
|
const exists = async (p) => {
|
|
510
624
|
try {
|
|
511
625
|
await access(p);
|
|
@@ -660,29 +774,53 @@ async function writeConfig(config) {
|
|
|
660
774
|
return false;
|
|
661
775
|
}
|
|
662
776
|
}
|
|
663
|
-
/**
|
|
664
|
-
|
|
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) {
|
|
665
788
|
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
|
|
666
789
|
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
|
|
667
790
|
if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
|
|
668
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";
|
|
669
792
|
if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) return "image/bmp";
|
|
670
|
-
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";
|
|
671
804
|
}
|
|
672
805
|
/**
|
|
673
806
|
* Read one slot as a data URL, or null when it has nothing to serve.
|
|
674
807
|
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
* lazily, so
|
|
679
|
-
*
|
|
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.
|
|
680
818
|
*/
|
|
681
819
|
async function readImage(slot) {
|
|
682
|
-
const asset =
|
|
820
|
+
const asset = holidayAssetFor(slot);
|
|
683
821
|
if (asset !== null) {
|
|
684
|
-
const
|
|
685
|
-
return
|
|
822
|
+
const art = await readHolidayAsset(asset);
|
|
823
|
+
return art === null ? null : toDataUrl(art);
|
|
686
824
|
}
|
|
687
825
|
const own = await readFileOrNull(imagePath(slot));
|
|
688
826
|
return own === null ? null : toDataUrl(own);
|
|
@@ -697,24 +835,245 @@ async function readFileOrNull(path) {
|
|
|
697
835
|
function toDataUrl(buf) {
|
|
698
836
|
return `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
699
837
|
}
|
|
700
|
-
/**
|
|
701
|
-
|
|
702
|
-
|
|
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;
|
|
703
875
|
}
|
|
704
876
|
/**
|
|
705
|
-
*
|
|
877
|
+
* One asset over the wire, or null for every way it can fail.
|
|
706
878
|
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
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.
|
|
711
890
|
*/
|
|
712
|
-
function
|
|
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
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* The wallpaper a holiday slot resolves to, or null when the slot is not a
|
|
1063
|
+
* holiday's.
|
|
1064
|
+
*
|
|
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.
|
|
1070
|
+
*/
|
|
1071
|
+
function holidayAssetFor(slot) {
|
|
713
1072
|
return HOLIDAYS.find((h) => h.slot === slot)?.asset ?? null;
|
|
714
1073
|
}
|
|
715
1074
|
/** Persist one rule image (null removes it); false keeps the previous file. */
|
|
716
1075
|
async function writeImage(slot, dataUrl) {
|
|
717
|
-
if (
|
|
1076
|
+
if (holidayAssetFor(slot) !== null) return false;
|
|
718
1077
|
await ensureDir();
|
|
719
1078
|
try {
|
|
720
1079
|
if (dataUrl === null) {
|
|
@@ -739,11 +1098,46 @@ async function listSlots() {
|
|
|
739
1098
|
}
|
|
740
1099
|
}
|
|
741
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
|
+
/**
|
|
742
1136
|
* Download an image from a network URL into one slot (replacing its bytes).
|
|
743
1137
|
* Returns { ok, dataUrl?, error? }; never throws.
|
|
744
1138
|
*/
|
|
745
1139
|
async function fetchImageUrl(slot, url) {
|
|
746
|
-
if (
|
|
1140
|
+
if (holidayAssetFor(slot) !== null) return {
|
|
747
1141
|
ok: false,
|
|
748
1142
|
error: "read-only slot"
|
|
749
1143
|
};
|
|
@@ -896,6 +1290,26 @@ async function handleRpcMethod(ctx, endpoint, payload) {
|
|
|
896
1290
|
ok: true,
|
|
897
1291
|
value: await fetchImageUrl(slot, payload?.url ?? null)
|
|
898
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
|
+
};
|
|
899
1313
|
case "defaultModel": return {
|
|
900
1314
|
ok: true,
|
|
901
1315
|
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.6",
|
|
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
|