dsh-background-by-model 0.7.6 → 0.7.8

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
@@ -87,11 +87,11 @@ const PART_OPACITY_KEYS = [
87
87
  "input"
88
88
  ];
89
89
  const DEFAULT_PART_OPACITIES = {
90
- bg: .85,
91
- sidebar: .93,
90
+ bg: 0,
91
+ sidebar: .1,
92
92
  card: 1,
93
- code: 1,
94
- input: 1
93
+ code: .81,
94
+ input: .88
95
95
  };
96
96
  /**
97
97
  * Interface blur (px, 0..60), global (Interface page):
@@ -122,7 +122,7 @@ const DEFAULT_PART_BLURS = {
122
122
  chat: 0,
123
123
  trajectory: 0,
124
124
  rightbar: 0,
125
- input: 0
125
+ input: 60
126
126
  };
127
127
  /** Longest allowed switch duration; 0 is a hard cut. */
128
128
  const TRANSITION_MAX_MS = 3e3;
@@ -146,6 +146,9 @@ const DEFAULT_TRANSITION = {
146
146
  easing: "ease",
147
147
  durationMs: 320
148
148
  };
149
+ const DEFAULT_CHAT_TEXT_OPACITY = .35;
150
+ const DEFAULT_TRAJECTORY_OPACITY = .73;
151
+ const DEFAULT_RIGHTBAR_OPACITY = .45;
149
152
  /**
150
153
  * `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
151
154
  * null when the string is not a hex color.
@@ -210,6 +213,35 @@ function defaultHolidayRule(def) {
210
213
  rotate: defaultRotation()
211
214
  };
212
215
  }
216
+ /**
217
+ * A rule with nothing in it yet: no picture, no match string, no color.
218
+ *
219
+ * Two states need this factory and they are the same state — a fresh install
220
+ * (see `freshThemeConfig` and `normalizeConfig`), and the "add rule" tile one
221
+ * entry further down the list.
222
+ *
223
+ * Such a rule is deliberately valid rather than half-built: it is skipped by
224
+ * matching until it has something of its own to paint (`ruleCanPaint` — a picture,
225
+ * or a theme color), which is what makes a rule nobody has filled in yet invisible
226
+ * instead of letting an empty background steal the fallback from rule 1.
227
+ *
228
+ * It used to be born with an allocated slot and an empty image entry, which is
229
+ * what put a blank tile at position 1 of a new rule's strip — and, worse, stopped
230
+ * the picture the user added next from being the first one (a rule paints its
231
+ * FIRST image). The slot is allocated when the first picture arrives.
232
+ */
233
+ function blankRule(id) {
234
+ return {
235
+ id,
236
+ images: [],
237
+ match: "",
238
+ enabled: true,
239
+ color: null,
240
+ wallpaperOpacity: 1,
241
+ blur: 0,
242
+ rotate: defaultRotation()
243
+ };
244
+ }
213
245
  /** Slot names reach the filesystem, so they are strictly whitelisted. */
214
246
  const SLOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
215
247
  function clamp(n, lo, hi, def) {
@@ -440,14 +472,36 @@ function normalizeHolidays(raw) {
440
472
  if (typeof id === "string") stored.set(id, item);
441
473
  }
442
474
  return {
443
- enabled: r.enabled !== false,
475
+ enabled: typeof r.enabled === "boolean" ? r.enabled : false,
444
476
  items: HOLIDAYS.map((def) => normalizeHolidayRule(def, stored.get(def.id)))
445
477
  };
446
478
  }
479
+ /**
480
+ * The file-preview panel's own opacity.
481
+ *
482
+ * Three states, and the middle one is why this is a function rather than the
483
+ * one-line fallback every other field gets: a real number is the card's own
484
+ * value, `null` is "never owned, so follow `opacities.bg`", and a KEY that is not
485
+ * there at all — a hand-written config, or one written before the option existed
486
+ * — is the shipped profile's 45% (see DEFAULT_RIGHTBAR_OPACITY).
487
+ *
488
+ * `null` has to survive as ITSELF instead of falling through to the default:
489
+ * every release so far has written it — a panel the user never dragged is
490
+ * persisted as an explicit `null` — so treating it as "missing" would hand the
491
+ * shipped 45% to every configuration whose preview panel was following the main
492
+ * background, a silent change to a setup nobody asked to change. It is also
493
+ * unreachable from the UI (no control returns to it); it exists for configs that
494
+ * were written while the shipped default was `null`.
495
+ */
496
+ function normalizeRightbarOpacity(v) {
497
+ if (v === null) return null;
498
+ if (typeof v === "number" && isFinite(v)) return clamp01(v, 1);
499
+ return DEFAULT_RIGHTBAR_OPACITY;
500
+ }
447
501
  /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
448
502
  function normalizeConfig(raw) {
449
503
  const r = raw ?? {};
450
- const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [];
504
+ const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [blankRule("r1")];
451
505
  const ops = r.opacities ?? {};
452
506
  const bl = r.blurs ?? {};
453
507
  const blurs = {};
@@ -460,13 +514,49 @@ function normalizeConfig(raw) {
460
514
  blurs,
461
515
  transition: normalizeTransition(r.transition, legacyFadeMs(r.rules)),
462
516
  settingsOpacity: clamp01(r.settingsOpacity, 1),
463
- chatTextOpacity: clamp01(r.chatTextOpacity, 0),
464
- trajectoryOpacity: clamp01(r.trajectoryOpacity, 1),
465
- rightbarOpacity: typeof r.rightbarOpacity === "number" && isFinite(r.rightbarOpacity) ? clamp01(r.rightbarOpacity, 1) : null,
517
+ chatTextOpacity: clamp01(r.chatTextOpacity, DEFAULT_CHAT_TEXT_OPACITY),
518
+ trajectoryOpacity: clamp01(r.trajectoryOpacity, DEFAULT_TRAJECTORY_OPACITY),
519
+ rightbarOpacity: normalizeRightbarOpacity(r.rightbarOpacity),
466
520
  autoExtract: r.autoExtract !== false,
467
521
  holidays: normalizeHolidays(r.holidays)
468
522
  };
469
523
  }
524
+ /**
525
+ * Whether another round is worth asking for.
526
+ *
527
+ * Three reasons are terminal, and each for its own:
528
+ *
529
+ * - `missing` — the host says the file is not there. The profile follows a
530
+ * BRANCH (see `PRESET_ASSET_HOSTS` in ./index), so a renamed or deleted file
531
+ * has to reach the user as a loud failure NAMING the slot — and it has to
532
+ * reach them FAST: paying two more rounds for a file that is provably absent
533
+ * only makes the sentence later, on a button the user is watching.
534
+ * - `oversized` — the bytes are the problem, and the next round would serve the
535
+ * same bytes.
536
+ * - `timeout` — sixty seconds of silence is not weather. Both mirrors were
537
+ * already asked inside the SAME round, so a retry round would re-ask two hosts
538
+ * that each just spent a minute not answering. This is also what keeps the
539
+ * worst case for one file at three minutes instead of six: a round in which BOTH
540
+ * mirrors were silent is over immediately (two minutes, exactly what it cost
541
+ * before there were rounds at all), and only a round that mixed a silent mirror
542
+ * with a transient one can carry a minute into the next round.
543
+ *
544
+ * Everything else is a hiccup: a thrown connection, a 5xx, a 429, a 408, or a 200
545
+ * with no body.
546
+ */
547
+ function presetRetryable(reason) {
548
+ return reason !== "missing" && reason !== "oversized" && reason !== "timeout";
549
+ }
550
+ /**
551
+ * The delay before the round that follows `failedAttempt` (1-based), in ms.
552
+ *
553
+ * Doubling rather than fixed: the second retry is the one that has to survive a
554
+ * mirror having a bad few hundred milliseconds, and it is still short enough that
555
+ * a user staring at "downloading" does not read it as a hang.
556
+ */
557
+ function presetRetryDelayMs(failedAttempt) {
558
+ return 400 * 2 ** Math.max(0, failedAttempt - 1);
559
+ }
470
560
  //#endregion
471
561
  //#region src/index.ts
472
562
  /**
@@ -636,12 +726,18 @@ const exists = async (p) => {
636
726
  * layout mode, opacity, blur and framing, and the remaining legacy files
637
727
  * (background video, upload temp) are removed. Idempotent: the rewritten config
638
728
  * carries `rules`, so the next read short-circuits.
729
+ *
730
+ * The one case that is NOT a migration — a store with no config file at all — is
731
+ * handed through untouched rather than being filled in as `rules: []`. That empty
732
+ * list would be the same thing the sanitizer reads as "the user deleted every
733
+ * rule", and a fresh install is the opposite of that: `normalizeConfig` turns a
734
+ * config that names no rule list into the one blank rule the panel opens on.
639
735
  */
640
736
  async function migrateLegacy(raw) {
641
737
  const r = raw ?? {};
642
738
  if (Array.isArray(r.rules)) return r;
643
739
  const hasWallpaper = await exists(legacyWallpaperPath());
644
- if (Object.keys(r).length === 0 && !hasWallpaper) return { rules: [] };
740
+ if (Object.keys(r).length === 0 && !hasWallpaper) return {};
645
741
  if (hasWallpaper) {
646
742
  const target = imagePath("m1");
647
743
  try {
@@ -873,14 +969,20 @@ async function downloadHolidayAsset(asset) {
873
969
  }
874
970
  return null;
875
971
  }
972
+ /** Wait, without pulling in `node:timers/promises` for one line. */
973
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
876
974
  /**
877
- * One asset over the wire, or null for every way it can fail.
975
+ * One asset over the wire, or WHY it did not arrive.
878
976
  *
879
977
  * 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.
978
+ * today, and a profile file that cannot be fetched is a sentence on the button —
979
+ * neither is an error path the caller should have to wrap. What it answers with
980
+ * instead is the reason, because the two callers treat those differently: the
981
+ * profile asks `presetRetryable` whether the reason is weather (./preset) and the
982
+ * festival art takes the null and waits for the next apply.
983
+ *
984
+ * The `content-length` check is a courtesy — a mirror that announces a huge body
985
+ * never gets to send it — while the post-read size check is the one that holds.
884
986
  *
885
987
  * Deliberately says nothing about WHAT the bytes are: the festival art and the
886
988
  * recommended profile are both "a file from the assets repository", and the one
@@ -889,37 +991,89 @@ async function downloadHolidayAsset(asset) {
889
991
  * asked for.
890
992
  */
891
993
  async function fetchAsset(url, max, timeout) {
994
+ const ctl = new AbortController();
995
+ const timer = setTimeout(() => ctl.abort(), timeout);
892
996
  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
- }
997
+ const res = await fetch(url, {
998
+ redirect: "follow",
999
+ signal: ctl.signal
1000
+ });
1001
+ if (!res.ok) return {
1002
+ ok: false,
1003
+ reason: fetchFailureForStatus(res.status)
1004
+ };
1005
+ const announced = Number(res.headers.get("content-length") ?? "");
1006
+ if (isFinite(announced) && announced > max) return {
1007
+ ok: false,
1008
+ reason: "oversized"
1009
+ };
1010
+ const arr = await res.arrayBuffer();
1011
+ if (arr.byteLength > max) return {
1012
+ ok: false,
1013
+ reason: "oversized"
1014
+ };
1015
+ if (arr.byteLength === 0) return {
1016
+ ok: false,
1017
+ reason: "empty"
1018
+ };
1019
+ return {
1020
+ ok: true,
1021
+ buf: Buffer.from(arr)
1022
+ };
909
1023
  } catch {
910
- return null;
1024
+ return {
1025
+ ok: false,
1026
+ reason: ctl.signal.aborted ? "timeout" : "network"
1027
+ };
1028
+ } finally {
1029
+ clearTimeout(timer);
911
1030
  }
912
1031
  }
913
- /** One festival picture, or null when no mirror served an image. */
1032
+ /**
1033
+ * The status codes worth another round: 5xx is a mirror having a bad moment, 429
1034
+ * is one asking to be left alone for a bit, 408 is one giving up on us.
1035
+ *
1036
+ * Every other non-2xx means the file is not there — jsDelivr answers a path
1037
+ * outside the repository with 403, both hosts answer a missing file with 404 — and
1038
+ * the profile follows a BRANCH, so that answer has to arrive fast and unchanged.
1039
+ */
1040
+ function fetchFailureForStatus(status) {
1041
+ return status >= 500 || status === 429 || status === 408 ? "server" : "missing";
1042
+ }
1043
+ /**
1044
+ * One festival picture, or null when no mirror served an image.
1045
+ *
1046
+ * One round, deliberately, unlike the profile's (see `fetchPresetFile`): this is
1047
+ * fetched during boot, on the day, before the first wallpaper is painted, and a
1048
+ * retry round would add its delay to that first paint. The art does not need one
1049
+ * either — it is cached once it arrives, and every later read tries again, while a
1050
+ * button press has no later read of its own.
1051
+ */
914
1052
  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;
1053
+ const got = await fetchAsset(url, HOLIDAY_FETCH_MAX, HOLIDAY_FETCH_TIMEOUT);
1054
+ return !got.ok || imageMimeOrNull(got.buf) === null ? null : got.buf;
917
1055
  }
918
- /** One file out of the profile directory, or null when no mirror served it. */
1056
+ /**
1057
+ * One file out of the profile directory, or null when no mirror served it.
1058
+ *
1059
+ * Every mirror is asked in every round, so a round is "all the hosts there are"
1060
+ * and the rounds are the retry: the case this exists for is a mirror that answers
1061
+ * 502 on one round and correctly on the next, on the sixth of seven files, with
1062
+ * six good downloads behind it. A round in which every failure was terminal — the
1063
+ * file is not there, or it is too big — ends the attempts outright, so "the author
1064
+ * renamed a file" still costs exactly two requests and a sentence naming the slot
1065
+ * (see `presetRetryable`, ./preset).
1066
+ */
919
1067
  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;
1068
+ for (let attempt = 1; attempt <= 3; attempt++) {
1069
+ let retryable = false;
1070
+ for (const host of PRESET_ASSET_HOSTS) {
1071
+ const got = await fetchAsset(`${host}/${name}`, max, PRESET_FETCH_TIMEOUT);
1072
+ if (got.ok) return got.buf;
1073
+ if (presetRetryable(got.reason)) retryable = true;
1074
+ }
1075
+ if (!retryable || attempt === 3) return null;
1076
+ await sleep(presetRetryDelayMs(attempt));
923
1077
  }
924
1078
  return null;
925
1079
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-background-by-model",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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",
@@ -60,9 +60,10 @@
60
60
  "check:code-plate": "node scripts/code-plate-check.ts",
61
61
  "check:image-mode": "node scripts/image-mode-check.ts",
62
62
  "check:rules": "node scripts/rules-view-check.ts",
63
+ "check:preset": "node scripts/preset-check.ts",
63
64
  "check:ui": "node scripts/ui-strings-check.ts",
64
65
  "check:node": "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"
66
+ "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/preset-check.ts && node scripts/ui-strings-check.ts && node scripts/node-half-check.mjs"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",