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/CHANGELOG.md +23 -1
- package/CHANGELOG.zh.md +23 -1
- package/README.md +20 -19
- package/README.zh.md +20 -19
- package/lib/client.js +214 -80
- package/lib/index.js +194 -40
- package/package.json +3 -2
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:
|
|
91
|
-
sidebar: .
|
|
90
|
+
bg: 0,
|
|
91
|
+
sidebar: .1,
|
|
92
92
|
card: 1,
|
|
93
|
-
code:
|
|
94
|
-
input:
|
|
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:
|
|
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
|
|
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,
|
|
464
|
-
trajectoryOpacity: clamp01(r.trajectoryOpacity,
|
|
465
|
-
rightbarOpacity:
|
|
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 {
|
|
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
|
|
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,
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|
1024
|
+
return {
|
|
1025
|
+
ok: false,
|
|
1026
|
+
reason: ctl.signal.aborted ? "timeout" : "network"
|
|
1027
|
+
};
|
|
1028
|
+
} finally {
|
|
1029
|
+
clearTimeout(timer);
|
|
911
1030
|
}
|
|
912
1031
|
}
|
|
913
|
-
/**
|
|
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
|
|
916
|
-
return
|
|
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
|
-
/**
|
|
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 (
|
|
921
|
-
|
|
922
|
-
|
|
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.
|
|
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",
|