dsh-any-background 0.2.8 → 0.2.10
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/README.md +33 -18
- package/README.zh.md +32 -18
- package/lib/client.js +1659 -218
- package/lib/client.js.map +1 -1
- package/lib/index.js +486 -55
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -27,10 +27,14 @@ const VIDEO_ROUTE = "/dsh-any-background/video";
|
|
|
27
27
|
const UPLOAD_ROUTE = "/dsh-any-background/video/upload";
|
|
28
28
|
const WALLPAPER_ROUTE = "/dsh-any-background/wallpaper";
|
|
29
29
|
const WALLPAPER_UPLOAD_ROUTE = "/dsh-any-background/wallpaper/upload";
|
|
30
|
+
const FONT_ROUTE = "/dsh-any-background/font";
|
|
31
|
+
const FONT_UPLOAD_ROUTE = "/dsh-any-background/font/upload";
|
|
30
32
|
const UPLOAD_TMP = "wallpaper.upload.tmp";
|
|
31
33
|
const VIDEO_UPLOAD_TMP = "video.upload.tmp";
|
|
34
|
+
const FONT_UPLOAD_TMP = "font.upload.tmp";
|
|
32
35
|
const WALLPAPER_UPLOAD_MAX = 104857600;
|
|
33
36
|
const VIDEO_UPLOAD_MAX = 2147483648;
|
|
37
|
+
const FONT_UPLOAD_MAX = 104857600;
|
|
34
38
|
const WALLPAPER_FETCH_MAX = 26214400;
|
|
35
39
|
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
36
40
|
const VIDEO_FETCH_MAX = 2147483648;
|
|
@@ -54,28 +58,100 @@ const VIDEO_CANDIDATES = [
|
|
|
54
58
|
"wallpaper.mkv",
|
|
55
59
|
"wallpaper.video"
|
|
56
60
|
];
|
|
61
|
+
/** Font slot: one font owns the slot, named by format like the video slot. */
|
|
62
|
+
function fontFileName(mime) {
|
|
63
|
+
switch (mime) {
|
|
64
|
+
case "font/woff2": return "font.woff2";
|
|
65
|
+
case "font/woff": return "font.woff";
|
|
66
|
+
case "font/otf": return "font.otf";
|
|
67
|
+
case "font/ttf": return "font.ttf";
|
|
68
|
+
default: return "font.ttf";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const FONT_CANDIDATES = [
|
|
72
|
+
"font.woff2",
|
|
73
|
+
"font.woff",
|
|
74
|
+
"font.otf",
|
|
75
|
+
"font.ttf"
|
|
76
|
+
];
|
|
77
|
+
/** Sniff a font's container format from its leading magic bytes (null when the
|
|
78
|
+
* bytes are not a recognized font — uploads are rejected rather than stored). */
|
|
79
|
+
function sniffFontMime(buf) {
|
|
80
|
+
if (buf.length >= 4 && buf[0] === 119 && buf[1] === 79 && buf[2] === 70 && buf[3] === 50) return "font/woff2";
|
|
81
|
+
if (buf.length >= 4 && buf[0] === 119 && buf[1] === 79 && buf[2] === 70 && buf[3] === 70) return "font/woff";
|
|
82
|
+
if (buf.length >= 4 && buf[0] === 79 && buf[1] === 84 && buf[2] === 84 && buf[3] === 79) return "font/otf";
|
|
83
|
+
if (buf.length >= 4 && buf[0] === 0 && buf[1] === 1 && buf[2] === 0 && buf[3] === 0) return "font/ttf";
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
57
86
|
const DEFAULT_CONFIG = {
|
|
58
87
|
color: null,
|
|
59
88
|
opacities: {
|
|
60
|
-
bg: .
|
|
61
|
-
sidebar: .
|
|
62
|
-
card:
|
|
63
|
-
input:
|
|
89
|
+
bg: .5,
|
|
90
|
+
sidebar: .5,
|
|
91
|
+
card: .5,
|
|
92
|
+
input: .5
|
|
64
93
|
},
|
|
65
94
|
blurs: {
|
|
66
|
-
bg:
|
|
67
|
-
sidebar:
|
|
68
|
-
card:
|
|
69
|
-
settings:
|
|
70
|
-
chat:
|
|
71
|
-
trajectory:
|
|
72
|
-
input:
|
|
73
|
-
panel:
|
|
74
|
-
produced:
|
|
95
|
+
bg: 30,
|
|
96
|
+
sidebar: 30,
|
|
97
|
+
card: 30,
|
|
98
|
+
settings: 30,
|
|
99
|
+
chat: 30,
|
|
100
|
+
trajectory: 30,
|
|
101
|
+
input: 30,
|
|
102
|
+
panel: 30,
|
|
103
|
+
produced: 30
|
|
75
104
|
},
|
|
76
|
-
|
|
105
|
+
strokes: {
|
|
106
|
+
bg: {
|
|
107
|
+
width: 0,
|
|
108
|
+
color: "auto",
|
|
109
|
+
customColor: "#808080"
|
|
110
|
+
},
|
|
111
|
+
sidebar: {
|
|
112
|
+
width: 0,
|
|
113
|
+
color: "auto",
|
|
114
|
+
customColor: "#808080"
|
|
115
|
+
},
|
|
116
|
+
card: {
|
|
117
|
+
width: 0,
|
|
118
|
+
color: "auto",
|
|
119
|
+
customColor: "#808080"
|
|
120
|
+
},
|
|
121
|
+
settings: {
|
|
122
|
+
width: 0,
|
|
123
|
+
color: "auto",
|
|
124
|
+
customColor: "#808080"
|
|
125
|
+
},
|
|
126
|
+
chat: {
|
|
127
|
+
width: 0,
|
|
128
|
+
color: "auto",
|
|
129
|
+
customColor: "#808080"
|
|
130
|
+
},
|
|
131
|
+
trajectory: {
|
|
132
|
+
width: 0,
|
|
133
|
+
color: "auto",
|
|
134
|
+
customColor: "#808080"
|
|
135
|
+
},
|
|
136
|
+
input: {
|
|
137
|
+
width: 0,
|
|
138
|
+
color: "auto",
|
|
139
|
+
customColor: "#808080"
|
|
140
|
+
},
|
|
141
|
+
panel: {
|
|
142
|
+
width: 0,
|
|
143
|
+
color: "auto",
|
|
144
|
+
customColor: "#808080"
|
|
145
|
+
},
|
|
146
|
+
produced: {
|
|
147
|
+
width: 0,
|
|
148
|
+
color: "auto",
|
|
149
|
+
customColor: "#808080"
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
settingsOpacity: .5,
|
|
77
153
|
wallpaperOpacity: 1,
|
|
78
|
-
blur:
|
|
154
|
+
blur: 30,
|
|
79
155
|
bgState: {
|
|
80
156
|
zoom: 1,
|
|
81
157
|
x: 0,
|
|
@@ -93,12 +169,14 @@ const DEFAULT_CONFIG = {
|
|
|
93
169
|
backgroundType: "image",
|
|
94
170
|
bgMode: "fit",
|
|
95
171
|
videoMime: null,
|
|
172
|
+
fontMime: null,
|
|
173
|
+
fontEnabled: true,
|
|
96
174
|
generatedBg: null,
|
|
97
175
|
regenerateOnReload: false,
|
|
98
|
-
chatTextOpacity:
|
|
99
|
-
trajectoryOpacity:
|
|
100
|
-
panelOpacity:
|
|
101
|
-
producedOpacity:
|
|
176
|
+
chatTextOpacity: .5,
|
|
177
|
+
trajectoryOpacity: .5,
|
|
178
|
+
panelOpacity: .5,
|
|
179
|
+
producedOpacity: .5,
|
|
102
180
|
profiles: [],
|
|
103
181
|
rotation: {
|
|
104
182
|
enabled: false,
|
|
@@ -123,6 +201,7 @@ const dataDir = () => dshHomePath(DATA_DIR);
|
|
|
123
201
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
124
202
|
const wallpaperPath = () => dshHomePath(DATA_DIR, WALLPAPER_FILE);
|
|
125
203
|
const videoPathFor = (mime) => dshHomePath(DATA_DIR, videoFileName(mime));
|
|
204
|
+
const fontPathFor = (mime) => dshHomePath(DATA_DIR, fontFileName(mime));
|
|
126
205
|
const exists = async (p) => {
|
|
127
206
|
try {
|
|
128
207
|
await access(p);
|
|
@@ -162,6 +241,43 @@ async function findVideoFile() {
|
|
|
162
241
|
function clamp(n, lo, hi, def) {
|
|
163
242
|
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
164
243
|
}
|
|
244
|
+
/** Locate the stored font: the recorded MIME decides the expected name; stray
|
|
245
|
+
* files from a lost config write are adopted via rename (mirrors the video
|
|
246
|
+
* slot's recovery). */
|
|
247
|
+
async function findFontFile() {
|
|
248
|
+
const mime = (await readConfig()).fontMime ?? "font/ttf";
|
|
249
|
+
const expected = fontPathFor(mime);
|
|
250
|
+
if (await exists(expected)) return {
|
|
251
|
+
path: expected,
|
|
252
|
+
mime
|
|
253
|
+
};
|
|
254
|
+
for (const name of FONT_CANDIDATES) {
|
|
255
|
+
const p = dshHomePath(DATA_DIR, name);
|
|
256
|
+
if (!await exists(p)) continue;
|
|
257
|
+
const foundMime = mimeForFontFile(name);
|
|
258
|
+
try {
|
|
259
|
+
await rename(p, expected);
|
|
260
|
+
return {
|
|
261
|
+
path: expected,
|
|
262
|
+
mime: foundMime
|
|
263
|
+
};
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function mimeForFontFile(name) {
|
|
271
|
+
switch (name) {
|
|
272
|
+
case "font.woff2": return "font/woff2";
|
|
273
|
+
case "font.woff": return "font/woff";
|
|
274
|
+
case "font.otf": return "font/otf";
|
|
275
|
+
default: return "font/ttf";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function fontUrl() {
|
|
279
|
+
return await findFontFile() ? FONT_ROUTE : null;
|
|
280
|
+
}
|
|
165
281
|
function normalizeBgState(s) {
|
|
166
282
|
return {
|
|
167
283
|
zoom: clamp(s.zoom, .1, 10, 1),
|
|
@@ -172,6 +288,40 @@ function normalizeBgState(s) {
|
|
|
172
288
|
};
|
|
173
289
|
}
|
|
174
290
|
/** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
|
|
291
|
+
const STROKE_GROUPS = [
|
|
292
|
+
"bg",
|
|
293
|
+
"sidebar",
|
|
294
|
+
"card",
|
|
295
|
+
"settings",
|
|
296
|
+
"chat",
|
|
297
|
+
"trajectory",
|
|
298
|
+
"input",
|
|
299
|
+
"panel",
|
|
300
|
+
"produced"
|
|
301
|
+
];
|
|
302
|
+
const STROKE_COLOR_KEYS = [
|
|
303
|
+
"auto",
|
|
304
|
+
"gray",
|
|
305
|
+
"black",
|
|
306
|
+
"white",
|
|
307
|
+
"theme",
|
|
308
|
+
"custom"
|
|
309
|
+
];
|
|
310
|
+
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
311
|
+
function normalizeStroke(raw) {
|
|
312
|
+
const s = raw ?? {};
|
|
313
|
+
return {
|
|
314
|
+
width: clamp(s.width, 0, 4, 0),
|
|
315
|
+
color: STROKE_COLOR_KEYS.includes(s.color) ? s.color : "auto",
|
|
316
|
+
customColor: typeof s.customColor === "string" && HEX_RE.test(s.customColor) ? s.customColor : "#808080"
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function normalizeStrokes(raw) {
|
|
320
|
+
const s = raw ?? {};
|
|
321
|
+
const out = {};
|
|
322
|
+
for (const k of STROKE_GROUPS) out[k] = normalizeStroke(s[k]);
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
175
325
|
function normalizeConfig(raw) {
|
|
176
326
|
const r = raw ?? {};
|
|
177
327
|
const c = r.color;
|
|
@@ -220,6 +370,7 @@ function normalizeConfig(raw) {
|
|
|
220
370
|
input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
|
|
221
371
|
},
|
|
222
372
|
blurs,
|
|
373
|
+
strokes: normalizeStrokes(r.strokes),
|
|
223
374
|
settingsOpacity: clamp(r.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
|
|
224
375
|
wallpaperOpacity: clamp(r.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
|
|
225
376
|
blur: clamp(r.blur, 0, 60, DEFAULT_CONFIG.blur),
|
|
@@ -228,6 +379,8 @@ function normalizeConfig(raw) {
|
|
|
228
379
|
backgroundType: bgType,
|
|
229
380
|
bgMode,
|
|
230
381
|
videoMime: typeof r.videoMime === "string" ? r.videoMime : null,
|
|
382
|
+
fontMime: typeof r.fontMime === "string" ? r.fontMime : null,
|
|
383
|
+
fontEnabled: typeof r.fontEnabled === "boolean" ? r.fontEnabled : DEFAULT_CONFIG.fontEnabled,
|
|
231
384
|
generatedBg,
|
|
232
385
|
regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
|
|
233
386
|
chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
|
|
@@ -311,6 +464,7 @@ function normalizeProfileAppearance(raw) {
|
|
|
311
464
|
input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
|
|
312
465
|
},
|
|
313
466
|
blurs,
|
|
467
|
+
strokes: normalizeStrokes(a.strokes),
|
|
314
468
|
settingsOpacity: clamp(a.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
|
|
315
469
|
wallpaperOpacity: clamp(a.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
|
|
316
470
|
blur: clamp(a.blur, 0, 60, DEFAULT_CONFIG.blur),
|
|
@@ -382,12 +536,41 @@ async function ensureDir() {
|
|
|
382
536
|
console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
|
|
383
537
|
}
|
|
384
538
|
}
|
|
539
|
+
let configCacheKey = null;
|
|
540
|
+
let configCacheValue = null;
|
|
541
|
+
function dropConfigCache() {
|
|
542
|
+
configCacheKey = null;
|
|
543
|
+
configCacheValue = null;
|
|
544
|
+
}
|
|
385
545
|
async function readConfig() {
|
|
386
546
|
await ensureDir();
|
|
547
|
+
const file = configPath();
|
|
548
|
+
let text;
|
|
549
|
+
let key;
|
|
387
550
|
try {
|
|
388
|
-
const
|
|
389
|
-
|
|
551
|
+
const st = await stat(file);
|
|
552
|
+
key = {
|
|
553
|
+
mtimeMs: st.mtimeMs,
|
|
554
|
+
size: st.size
|
|
555
|
+
};
|
|
556
|
+
if (configCacheKey !== null && configCacheValue !== null && configCacheKey.mtimeMs === key.mtimeMs && configCacheKey.size === key.size) return configCacheValue;
|
|
557
|
+
text = await readFile(file, "utf8");
|
|
390
558
|
} catch {
|
|
559
|
+
dropConfigCache();
|
|
560
|
+
return { ...DEFAULT_CONFIG };
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const parsed = normalizeConfig(JSON.parse(text));
|
|
564
|
+
configCacheKey = key;
|
|
565
|
+
configCacheValue = parsed;
|
|
566
|
+
return parsed;
|
|
567
|
+
} catch (e) {
|
|
568
|
+
try {
|
|
569
|
+
const into = `${file}.corrupt-${Date.now()}`;
|
|
570
|
+
await rename(file, into);
|
|
571
|
+
console.warn(`dsh-any-background: theme-config.json was corrupt, archived to "${into}" and reset to defaults`, e);
|
|
572
|
+
} catch {}
|
|
573
|
+
dropConfigCache();
|
|
391
574
|
return { ...DEFAULT_CONFIG };
|
|
392
575
|
}
|
|
393
576
|
}
|
|
@@ -406,7 +589,11 @@ function warnUnknownConfigKeys(raw, normalized) {
|
|
|
406
589
|
if (known.has(key) || LEGACY_CONFIG_KEYS.has(key)) continue;
|
|
407
590
|
warn(key);
|
|
408
591
|
}
|
|
409
|
-
for (const group of [
|
|
592
|
+
for (const group of [
|
|
593
|
+
"blurs",
|
|
594
|
+
"opacities",
|
|
595
|
+
"strokes"
|
|
596
|
+
]) {
|
|
410
597
|
const got = r[group];
|
|
411
598
|
if (got === null || typeof got !== "object") continue;
|
|
412
599
|
const have = new Set(Object.keys(normalized[group]));
|
|
@@ -415,13 +602,19 @@ function warnUnknownConfigKeys(raw, normalized) {
|
|
|
415
602
|
}
|
|
416
603
|
async function writeConfig(config) {
|
|
417
604
|
await ensureDir();
|
|
605
|
+
const tmp = `${configPath()}.tmp`;
|
|
418
606
|
try {
|
|
419
607
|
const normalized = normalizeConfig(config);
|
|
420
608
|
warnUnknownConfigKeys(config, normalized);
|
|
421
|
-
await writeFile(
|
|
609
|
+
await writeFile(tmp, JSON.stringify(normalized, null, 2), "utf8");
|
|
610
|
+
await rename(tmp, configPath());
|
|
611
|
+
dropConfigCache();
|
|
422
612
|
return true;
|
|
423
613
|
} catch (e) {
|
|
424
614
|
console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
|
|
615
|
+
try {
|
|
616
|
+
await rm(tmp, { force: true });
|
|
617
|
+
} catch {}
|
|
425
618
|
return false;
|
|
426
619
|
}
|
|
427
620
|
}
|
|
@@ -460,6 +653,17 @@ function sniffImageMime(buf) {
|
|
|
460
653
|
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";
|
|
461
654
|
return "image/jpeg";
|
|
462
655
|
}
|
|
656
|
+
/** True when the leading bytes are a container the serve route knows how to
|
|
657
|
+
* sniff. The Content-Type only reflects what the server claims; validating the
|
|
658
|
+
* bytes themselves rejects a mislabeled or hostile payload with a clear error
|
|
659
|
+
* instead of persisting a file that renders as a broken image. */
|
|
660
|
+
function isImageBytes(buf) {
|
|
661
|
+
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return true;
|
|
662
|
+
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return true;
|
|
663
|
+
if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return true;
|
|
664
|
+
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 true;
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
463
667
|
/** Download a wallpaper from a network URL and persist it into the local
|
|
464
668
|
* wallpaper.jpg slot (replacing whatever was stored), so type switches and
|
|
465
669
|
* rotation keep working through the single active slot. The response carries
|
|
@@ -532,6 +736,10 @@ async function writeWallpaperFromUrl(url) {
|
|
|
532
736
|
error: "read failed"
|
|
533
737
|
};
|
|
534
738
|
}
|
|
739
|
+
if (!isImageBytes(buf)) return {
|
|
740
|
+
ok: false,
|
|
741
|
+
error: "not an image"
|
|
742
|
+
};
|
|
535
743
|
await ensureDir();
|
|
536
744
|
try {
|
|
537
745
|
await writeFile(wallpaperPath(), buf);
|
|
@@ -753,21 +961,28 @@ async function writeVideoFromUrl(url) {
|
|
|
753
961
|
const out = createWriteStream(tmp);
|
|
754
962
|
let received = 0;
|
|
755
963
|
let failed = false;
|
|
756
|
-
const fail = () => {
|
|
757
|
-
if (failed) return;
|
|
758
|
-
failed = true;
|
|
759
|
-
out.destroy();
|
|
760
|
-
rm(tmp, { force: true });
|
|
761
|
-
};
|
|
762
964
|
const nodeStream = Readable.fromWeb(res.body);
|
|
763
965
|
let idle = null;
|
|
966
|
+
const clearIdle = () => {
|
|
967
|
+
if (idle !== null) {
|
|
968
|
+
clearTimeout(idle);
|
|
969
|
+
idle = null;
|
|
970
|
+
}
|
|
971
|
+
};
|
|
764
972
|
const pokeIdle = () => {
|
|
765
|
-
|
|
973
|
+
clearIdle();
|
|
766
974
|
idle = setTimeout(() => {
|
|
767
975
|
nodeStream.destroy();
|
|
768
976
|
fail();
|
|
769
977
|
}, VIDEO_FETCH_IDLE_TIMEOUT);
|
|
770
978
|
};
|
|
979
|
+
const fail = () => {
|
|
980
|
+
if (failed) return;
|
|
981
|
+
failed = true;
|
|
982
|
+
clearIdle();
|
|
983
|
+
out.destroy();
|
|
984
|
+
rmWhenClosed(out, tmp);
|
|
985
|
+
};
|
|
771
986
|
nodeStream.on("data", (chunk) => {
|
|
772
987
|
pokeIdle();
|
|
773
988
|
received += chunk.byteLength;
|
|
@@ -776,10 +991,7 @@ async function writeVideoFromUrl(url) {
|
|
|
776
991
|
fail();
|
|
777
992
|
}
|
|
778
993
|
});
|
|
779
|
-
nodeStream.on("end",
|
|
780
|
-
if (idle) clearTimeout(idle);
|
|
781
|
-
idle = null;
|
|
782
|
-
});
|
|
994
|
+
nodeStream.on("end", clearIdle);
|
|
783
995
|
nodeStream.on("aborted", fail);
|
|
784
996
|
nodeStream.on("error", fail);
|
|
785
997
|
out.on("error", fail);
|
|
@@ -843,6 +1055,37 @@ async function writeVideo(dataUrl) {
|
|
|
843
1055
|
return false;
|
|
844
1056
|
}
|
|
845
1057
|
}
|
|
1058
|
+
/** Drop a partial upload's temp file once its sink is really closed. Windows
|
|
1059
|
+
* refuses to unlink a file that still has an open handle, so removing it in the
|
|
1060
|
+
* same tick as `out.destroy()` silently fails and leaves the aborted transfer
|
|
1061
|
+
* on disk (up to the full limit) until some later upload overwrites it. */
|
|
1062
|
+
function rmWhenClosed(out, tmp) {
|
|
1063
|
+
const drop = () => {
|
|
1064
|
+
rm(tmp, { force: true });
|
|
1065
|
+
};
|
|
1066
|
+
if (out.closed === true) drop();
|
|
1067
|
+
else out.once("close", drop);
|
|
1068
|
+
}
|
|
1069
|
+
/** Reject an oversized upload with a real status and tear the transfer down.
|
|
1070
|
+
* The bare req.destroy()+fail() path left the client holding a network error
|
|
1071
|
+
* with no way to tell "too large" from "connection died". The body is machine
|
|
1072
|
+
* readable (`error` + the enforced `limit`) so the panel can phrase the
|
|
1073
|
+
* refusal in the user's own language, with `message` kept for logs. */
|
|
1074
|
+
function rejectOversizedUpload(req, res, fail, limit) {
|
|
1075
|
+
try {
|
|
1076
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1077
|
+
res.end(JSON.stringify({
|
|
1078
|
+
ok: false,
|
|
1079
|
+
error: "too large",
|
|
1080
|
+
limit,
|
|
1081
|
+
message: `too large (limit ${limit})`
|
|
1082
|
+
}));
|
|
1083
|
+
} catch {}
|
|
1084
|
+
fail();
|
|
1085
|
+
try {
|
|
1086
|
+
req.destroy();
|
|
1087
|
+
} catch {}
|
|
1088
|
+
}
|
|
846
1089
|
/** Stream the stored video: correct MIME, no caching, Range answers so the
|
|
847
1090
|
* browser can seek (snapshot capture does). */
|
|
848
1091
|
async function serveVideo(req, res) {
|
|
@@ -946,16 +1189,14 @@ async function handleVideoUpload(req, res) {
|
|
|
946
1189
|
if (failed) return;
|
|
947
1190
|
failed = true;
|
|
948
1191
|
out.destroy();
|
|
949
|
-
|
|
1192
|
+
rmWhenClosed(out, tmp);
|
|
950
1193
|
};
|
|
951
1194
|
req.on("aborted", fail);
|
|
952
1195
|
req.on("error", fail);
|
|
953
1196
|
req.on("data", (chunk) => {
|
|
1197
|
+
if (failed) return;
|
|
954
1198
|
received += chunk.byteLength;
|
|
955
|
-
if (received > VIDEO_UPLOAD_MAX)
|
|
956
|
-
req.destroy();
|
|
957
|
-
fail();
|
|
958
|
-
}
|
|
1199
|
+
if (received > VIDEO_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "2 GB");
|
|
959
1200
|
});
|
|
960
1201
|
out.on("error", () => {
|
|
961
1202
|
fail();
|
|
@@ -1068,7 +1309,7 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1068
1309
|
if (failed) return;
|
|
1069
1310
|
failed = true;
|
|
1070
1311
|
out.destroy();
|
|
1071
|
-
|
|
1312
|
+
rmWhenClosed(out, tmp);
|
|
1072
1313
|
};
|
|
1073
1314
|
req.on("aborted", fail);
|
|
1074
1315
|
req.on("error", fail);
|
|
@@ -1080,11 +1321,9 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1080
1321
|
} catch {}
|
|
1081
1322
|
});
|
|
1082
1323
|
req.on("data", (chunk) => {
|
|
1324
|
+
if (failed) return;
|
|
1083
1325
|
received += chunk.byteLength;
|
|
1084
|
-
if (received > WALLPAPER_UPLOAD_MAX)
|
|
1085
|
-
req.destroy();
|
|
1086
|
-
fail();
|
|
1087
|
-
}
|
|
1326
|
+
if (received > WALLPAPER_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
|
|
1088
1327
|
});
|
|
1089
1328
|
req.pipe(out);
|
|
1090
1329
|
out.on("finish", async () => {
|
|
@@ -1123,6 +1362,162 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1123
1362
|
} catch {}
|
|
1124
1363
|
}
|
|
1125
1364
|
}
|
|
1365
|
+
/** Stream the stored custom font: sniffed MIME, no caching (uploads replace
|
|
1366
|
+
* the file in place). Fonts need no Range support — the browser fetches once. */
|
|
1367
|
+
async function serveFont(req, res) {
|
|
1368
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1369
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
1370
|
+
res.end(JSON.stringify({
|
|
1371
|
+
ok: false,
|
|
1372
|
+
error: "font route only serves GET/HEAD"
|
|
1373
|
+
}));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
const found = await findFontFile();
|
|
1378
|
+
if (found === null) {
|
|
1379
|
+
res.writeHead(404);
|
|
1380
|
+
res.end("no custom font stored");
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const st = await stat(found.path);
|
|
1384
|
+
res.writeHead(200, {
|
|
1385
|
+
"Content-Type": found.mime,
|
|
1386
|
+
"Content-Length": st.size,
|
|
1387
|
+
"Cache-Control": "no-store"
|
|
1388
|
+
});
|
|
1389
|
+
if (req.method === "HEAD") {
|
|
1390
|
+
res.end();
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
createReadStream(found.path).pipe(res);
|
|
1394
|
+
} catch (e) {
|
|
1395
|
+
console.error("dsh-any-background: failed to serve the custom font", e);
|
|
1396
|
+
try {
|
|
1397
|
+
res.writeHead(500);
|
|
1398
|
+
res.end();
|
|
1399
|
+
} catch {}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/** Accept a raw font upload (POST): pipe the body into a temp file, sniff the
|
|
1403
|
+
* container format from its magic bytes (rejecting anything that is not a
|
|
1404
|
+
* recognizable font), then rename it into the format-derived slot and record
|
|
1405
|
+
* the MIME in the config so serve/find resolve immediately. */
|
|
1406
|
+
async function handleFontUpload(req, res) {
|
|
1407
|
+
if (req.method !== "POST") {
|
|
1408
|
+
res.writeHead(405);
|
|
1409
|
+
res.end();
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
await ensureDir();
|
|
1414
|
+
const tmp = dshHomePath(DATA_DIR, FONT_UPLOAD_TMP);
|
|
1415
|
+
const out = createWriteStream(tmp);
|
|
1416
|
+
let received = 0;
|
|
1417
|
+
let failed = false;
|
|
1418
|
+
const fail = () => {
|
|
1419
|
+
if (failed) return;
|
|
1420
|
+
failed = true;
|
|
1421
|
+
out.destroy();
|
|
1422
|
+
rmWhenClosed(out, tmp);
|
|
1423
|
+
};
|
|
1424
|
+
req.on("aborted", fail);
|
|
1425
|
+
req.on("error", fail);
|
|
1426
|
+
out.on("error", () => {
|
|
1427
|
+
fail();
|
|
1428
|
+
try {
|
|
1429
|
+
res.writeHead(500);
|
|
1430
|
+
res.end();
|
|
1431
|
+
} catch {}
|
|
1432
|
+
});
|
|
1433
|
+
req.on("data", (chunk) => {
|
|
1434
|
+
if (failed) return;
|
|
1435
|
+
received += chunk.byteLength;
|
|
1436
|
+
if (received > FONT_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
|
|
1437
|
+
});
|
|
1438
|
+
req.pipe(out);
|
|
1439
|
+
out.on("finish", async () => {
|
|
1440
|
+
if (failed) return;
|
|
1441
|
+
try {
|
|
1442
|
+
if (received === 0) {
|
|
1443
|
+
fail();
|
|
1444
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1445
|
+
res.end(JSON.stringify({
|
|
1446
|
+
ok: false,
|
|
1447
|
+
error: "empty upload"
|
|
1448
|
+
}));
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
const mime = sniffFontMime(await new Promise((resolve, reject) => {
|
|
1452
|
+
const chunks = [];
|
|
1453
|
+
const s = createReadStream(tmp, {
|
|
1454
|
+
start: 0,
|
|
1455
|
+
end: 15
|
|
1456
|
+
});
|
|
1457
|
+
s.on("data", (c) => chunks.push(c));
|
|
1458
|
+
s.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1459
|
+
s.on("error", reject);
|
|
1460
|
+
}));
|
|
1461
|
+
if (mime === null) {
|
|
1462
|
+
fail();
|
|
1463
|
+
res.writeHead(415, { "Content-Type": "application/json" });
|
|
1464
|
+
res.end(JSON.stringify({
|
|
1465
|
+
ok: false,
|
|
1466
|
+
error: "not a font (expected ttf/otf/woff/woff2)"
|
|
1467
|
+
}));
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const target = fontPathFor(mime);
|
|
1471
|
+
for (const name of FONT_CANDIDATES) {
|
|
1472
|
+
const p = dshHomePath(DATA_DIR, name);
|
|
1473
|
+
if (p !== target) await rm(p, { force: true });
|
|
1474
|
+
}
|
|
1475
|
+
await rm(target, { force: true });
|
|
1476
|
+
await rename(tmp, target);
|
|
1477
|
+
const cfg = await readConfig();
|
|
1478
|
+
if (cfg.fontMime !== mime) {
|
|
1479
|
+
cfg.fontMime = mime;
|
|
1480
|
+
await writeConfig(cfg);
|
|
1481
|
+
}
|
|
1482
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1483
|
+
res.end(JSON.stringify({
|
|
1484
|
+
ok: true,
|
|
1485
|
+
fontUrl: FONT_ROUTE,
|
|
1486
|
+
mime
|
|
1487
|
+
}));
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
console.error("dsh-any-background: failed to finalize the font upload", e);
|
|
1490
|
+
rm(tmp, { force: true });
|
|
1491
|
+
try {
|
|
1492
|
+
res.writeHead(500);
|
|
1493
|
+
res.end();
|
|
1494
|
+
} catch {}
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
} catch (e) {
|
|
1498
|
+
console.error("dsh-any-background: failed to accept the font upload", e);
|
|
1499
|
+
try {
|
|
1500
|
+
res.writeHead(500);
|
|
1501
|
+
res.end();
|
|
1502
|
+
} catch {}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/** Remove every stored font variant and clear the recorded MIME. */
|
|
1506
|
+
async function removeFontFile() {
|
|
1507
|
+
try {
|
|
1508
|
+
for (const name of FONT_CANDIDATES) await rm(dshHomePath(DATA_DIR, name), { force: true });
|
|
1509
|
+
await rm(dshHomePath(DATA_DIR, FONT_UPLOAD_TMP), { force: true });
|
|
1510
|
+
const cfg = await readConfig();
|
|
1511
|
+
if (cfg.fontMime !== null) {
|
|
1512
|
+
cfg.fontMime = null;
|
|
1513
|
+
await writeConfig(cfg);
|
|
1514
|
+
}
|
|
1515
|
+
return true;
|
|
1516
|
+
} catch (e) {
|
|
1517
|
+
console.error("dsh-any-background: failed to remove the custom font", e);
|
|
1518
|
+
return false;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1126
1521
|
const NS = "dshAnyBackground";
|
|
1127
1522
|
const RPC_CHANNEL = "/dsh-any-background";
|
|
1128
1523
|
const RPC_BODY_MAX = 314572800;
|
|
@@ -1142,8 +1537,7 @@ function isoWeekKey(d) {
|
|
|
1142
1537
|
* old one never reaches the screen. The client's maybeRotate stays as a
|
|
1143
1538
|
* fallback and skips when this already advanced (the `rotated` flag). */
|
|
1144
1539
|
async function advanceRotationIfDue() {
|
|
1145
|
-
const
|
|
1146
|
-
const rot = cfg.rotation;
|
|
1540
|
+
const rot = (await readConfig()).rotation;
|
|
1147
1541
|
if (!rot.enabled || rot.items.length === 0) return false;
|
|
1148
1542
|
const now = /* @__PURE__ */ new Date();
|
|
1149
1543
|
const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
|
|
@@ -1161,12 +1555,14 @@ async function advanceRotationIfDue() {
|
|
|
1161
1555
|
console.error("dsh-any-background: failed to advance the rotation pool", e);
|
|
1162
1556
|
return false;
|
|
1163
1557
|
}
|
|
1164
|
-
|
|
1165
|
-
...
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1558
|
+
if (!await writeConfig({
|
|
1559
|
+
...await readConfig(),
|
|
1560
|
+
rotation: {
|
|
1561
|
+
...rot,
|
|
1562
|
+
current: idx,
|
|
1563
|
+
lastRotate: now.toISOString()
|
|
1564
|
+
}
|
|
1565
|
+
})) return false;
|
|
1170
1566
|
return true;
|
|
1171
1567
|
}
|
|
1172
1568
|
/** Dispatch one decoded RPC method to the matching persistence routine and
|
|
@@ -1177,13 +1573,18 @@ async function handleRpcMethod(endpoint, payload) {
|
|
|
1177
1573
|
switch (method) {
|
|
1178
1574
|
case "read": {
|
|
1179
1575
|
const rotated = await advanceRotationIfDue();
|
|
1576
|
+
const config = await readConfig();
|
|
1577
|
+
const firstRun = !await exists(configPath());
|
|
1578
|
+
if (firstRun) await writeConfig(config);
|
|
1180
1579
|
return {
|
|
1181
1580
|
ok: true,
|
|
1182
1581
|
value: {
|
|
1183
|
-
config
|
|
1582
|
+
config,
|
|
1184
1583
|
wallpaperUrl: await wallpaperServeUrl(),
|
|
1185
1584
|
videoUrl: await videoUrl(),
|
|
1186
|
-
|
|
1585
|
+
fontUrl: await fontUrl(),
|
|
1586
|
+
rotated,
|
|
1587
|
+
firstRun
|
|
1187
1588
|
}
|
|
1188
1589
|
};
|
|
1189
1590
|
}
|
|
@@ -1219,6 +1620,10 @@ async function handleRpcMethod(endpoint, payload) {
|
|
|
1219
1620
|
ok: true,
|
|
1220
1621
|
value: await handleRotationSet(payload)
|
|
1221
1622
|
};
|
|
1623
|
+
case "removeFont": return {
|
|
1624
|
+
ok: true,
|
|
1625
|
+
value: await removeFontFile()
|
|
1626
|
+
};
|
|
1222
1627
|
default: return {
|
|
1223
1628
|
ok: false,
|
|
1224
1629
|
error: {
|
|
@@ -1263,6 +1668,22 @@ function apply(ctx) {
|
|
|
1263
1668
|
}));
|
|
1264
1669
|
return;
|
|
1265
1670
|
}
|
|
1671
|
+
const declaredLen = Number(req.headers["content-length"] ?? "");
|
|
1672
|
+
if (Number.isFinite(declaredLen) && declaredLen > RPC_BODY_MAX) {
|
|
1673
|
+
res.writeHead(413, {
|
|
1674
|
+
"Content-Type": "application/json",
|
|
1675
|
+
connection: "close"
|
|
1676
|
+
});
|
|
1677
|
+
res.end(JSON.stringify({
|
|
1678
|
+
ok: false,
|
|
1679
|
+
error: {
|
|
1680
|
+
code: "dsh-any-background/too-large",
|
|
1681
|
+
message: `body exceeds ${RPC_BODY_MAX} bytes; uploads must use the binary routes`,
|
|
1682
|
+
details: {}
|
|
1683
|
+
}
|
|
1684
|
+
}));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1266
1687
|
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
1267
1688
|
const endpoint = pathname.startsWith(`${RPC_CHANNEL}/`) ? pathname.slice(20) : void 0;
|
|
1268
1689
|
if (endpoint === void 0 || endpoint.length === 0) {
|
|
@@ -1364,7 +1785,17 @@ function apply(ctx) {
|
|
|
1364
1785
|
path: WALLPAPER_UPLOAD_ROUTE,
|
|
1365
1786
|
handler: fenceUpload(handleWallpaperUpload)
|
|
1366
1787
|
}), "dsh-any-background: wallpaper upload route");
|
|
1788
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1789
|
+
kind: "prefix",
|
|
1790
|
+
path: FONT_ROUTE,
|
|
1791
|
+
handler: serveFont
|
|
1792
|
+
}), "dsh-any-background: font route");
|
|
1793
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1794
|
+
kind: "exact",
|
|
1795
|
+
path: FONT_UPLOAD_ROUTE,
|
|
1796
|
+
handler: fenceUpload(handleFontUpload)
|
|
1797
|
+
}), "dsh-any-background: font upload route");
|
|
1367
1798
|
});
|
|
1368
1799
|
}
|
|
1369
1800
|
//#endregion
|
|
1370
|
-
export { apply, inject, name };
|
|
1801
|
+
export { DEFAULT_CONFIG, apply, inject, name };
|