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/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.8 keeps `bgMode` at the RULE
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
- /** Sniff an image's MIME from its leading magic bytes (defaults to JPEG). */
664
- function sniffImageMime(buf) {
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 "image/jpeg";
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
- * A holiday slot is answered from the package and from nowhere else — the data
676
- * directory is never consulted for it, which is what makes the festival art
677
- * impossible to swap. Everything else is the user's own bytes. Both are resolved
678
- * lazily, so nothing about the bundled art is loaded — or even looked for — until
679
- * a holiday actually reaches for it.
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 = bundledAssetFor(slot);
820
+ const asset = holidayAssetFor(slot);
683
821
  if (asset !== null) {
684
- const bundled = await readFileOrNull(bundledAssetPath(asset));
685
- return bundled === null ? null : toDataUrl(bundled);
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
- /** Absolute path of one wallpaper bundled inside the package. */
701
- function bundledAssetPath(asset) {
702
- return fileURLToPath(new URL(`../holiday/${asset}`, import.meta.url));
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
- * The wallpaper bundled for one slot, or null when the slot is not a holiday's.
877
+ * One asset over the wire, or null for every way it can fail.
706
878
  *
707
- * This mapping is the ONLY thing that makes a holiday slot read-only and
708
- * package-served, so both `readImage` and `writeImage` go through it rather than
709
- * each testing the holiday list on their own. It reads no config: the bundled art
710
- * has stopped being a fallback that a flag could disable.
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 bundledAssetFor(slot) {
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 (bundledAssetFor(slot) !== null) return false;
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 (bundledAssetFor(slot) !== null) return {
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.5",
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
Binary file
Binary file