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/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: .85,
92
- sidebar: .93,
90
+ bg: 0,
91
+ sidebar: .1,
93
92
  card: 1,
94
- code: 1,
95
- input: 1
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: 0
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.8 keeps `bgMode` at the RULE
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 !== false,
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, 0),
465
- trajectoryOpacity: clamp01(r.trajectoryOpacity, 1),
466
- rightbarOpacity: typeof r.rightbarOpacity === "number" && isFinite(r.rightbarOpacity) ? clamp01(r.rightbarOpacity, 1) : null,
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
- /** Sniff an image's MIME from its leading magic bytes (defaults to JPEG). */
664
- function sniffImageMime(buf) {
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 "image/jpeg";
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
- * 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.
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 = bundledAssetFor(slot);
845
+ const asset = holidayAssetFor(slot);
683
846
  if (asset !== null) {
684
- const bundled = await readFileOrNull(bundledAssetPath(asset));
685
- return bundled === null ? null : toDataUrl(bundled);
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
- /** Absolute path of one wallpaper bundled inside the package. */
701
- function bundledAssetPath(asset) {
702
- return fileURLToPath(new URL(`../holiday/${asset}`, import.meta.url));
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 bundled for one slot, or null when the slot is not a holiday's.
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 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.
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 bundledAssetFor(slot) {
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 (bundledAssetFor(slot) !== null) return false;
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 (bundledAssetFor(slot) !== null) return {
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.5",
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
Binary file
Binary file