dsh-any-background 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -12
- package/README.zh.md +33 -12
- package/lib/client.js +2167 -305
- package/lib/client.js.map +1 -1
- package/lib/index.js +658 -25
- package/package.json +133 -121
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
3
4
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
5
|
//#region src/index.ts
|
|
5
6
|
/**
|
|
@@ -21,11 +22,20 @@ const inject = ["connection", "webServer"];
|
|
|
21
22
|
const DATA_DIR = ".dsh-any-background-data";
|
|
22
23
|
const CONFIG_FILE = "theme-config.json";
|
|
23
24
|
const WALLPAPER_FILE = "wallpaper.jpg";
|
|
25
|
+
const ROTATION_DIR = "rotation";
|
|
24
26
|
const VIDEO_ROUTE = "/dsh-any-background/video";
|
|
25
27
|
const UPLOAD_ROUTE = "/dsh-any-background/video/upload";
|
|
28
|
+
const WALLPAPER_ROUTE = "/dsh-any-background/wallpaper";
|
|
29
|
+
const WALLPAPER_UPLOAD_ROUTE = "/dsh-any-background/wallpaper/upload";
|
|
26
30
|
const UPLOAD_TMP = "wallpaper.upload.tmp";
|
|
31
|
+
const VIDEO_UPLOAD_TMP = "video.upload.tmp";
|
|
32
|
+
const WALLPAPER_UPLOAD_MAX = 104857600;
|
|
33
|
+
const VIDEO_UPLOAD_MAX = 2147483648;
|
|
27
34
|
const WALLPAPER_FETCH_MAX = 26214400;
|
|
28
35
|
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
36
|
+
const VIDEO_FETCH_MAX = 2147483648;
|
|
37
|
+
const VIDEO_FETCH_TIMEOUT = 6e4;
|
|
38
|
+
const VIDEO_FETCH_IDLE_TIMEOUT = 6e4;
|
|
29
39
|
function videoFileName(mime) {
|
|
30
40
|
switch (mime) {
|
|
31
41
|
case "video/mp4": return "wallpaper.mp4";
|
|
@@ -84,7 +94,26 @@ const DEFAULT_CONFIG = {
|
|
|
84
94
|
generatedBg: null,
|
|
85
95
|
regenerateOnReload: false,
|
|
86
96
|
chatTextOpacity: 0,
|
|
87
|
-
trajectoryOpacity: 1
|
|
97
|
+
trajectoryOpacity: 1,
|
|
98
|
+
profiles: [],
|
|
99
|
+
rotation: {
|
|
100
|
+
enabled: false,
|
|
101
|
+
mode: "shuffle",
|
|
102
|
+
interval: "daily",
|
|
103
|
+
current: 0,
|
|
104
|
+
items: [],
|
|
105
|
+
lastRotate: null
|
|
106
|
+
},
|
|
107
|
+
schedule: {
|
|
108
|
+
enabled: false,
|
|
109
|
+
mode: "time",
|
|
110
|
+
dayProfile: null,
|
|
111
|
+
nightProfile: null,
|
|
112
|
+
dayStart: "07:00",
|
|
113
|
+
nightStart: "19:00"
|
|
114
|
+
},
|
|
115
|
+
schemeOverride: "auto",
|
|
116
|
+
activeProfile: null
|
|
88
117
|
};
|
|
89
118
|
const dataDir = () => dshHomePath(DATA_DIR);
|
|
90
119
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
@@ -196,7 +225,12 @@ function normalizeConfig(raw) {
|
|
|
196
225
|
generatedBg,
|
|
197
226
|
regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
|
|
198
227
|
chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
|
|
199
|
-
trajectoryOpacity: clamp(r.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity)
|
|
228
|
+
trajectoryOpacity: clamp(r.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity),
|
|
229
|
+
profiles: normalizeProfiles(r.profiles),
|
|
230
|
+
rotation: normalizeRotation(r.rotation),
|
|
231
|
+
schedule: normalizeSchedule(r.schedule),
|
|
232
|
+
schemeOverride: r.schemeOverride === "light" || r.schemeOverride === "dark" ? r.schemeOverride : "auto",
|
|
233
|
+
activeProfile: typeof r.activeProfile === "string" ? r.activeProfile : null
|
|
200
234
|
};
|
|
201
235
|
}
|
|
202
236
|
function normalizeGeneratedBg(p) {
|
|
@@ -211,7 +245,8 @@ function normalizeGeneratedBg(p) {
|
|
|
211
245
|
preset: [
|
|
212
246
|
"aurora",
|
|
213
247
|
"nebula",
|
|
214
|
-
"noise"
|
|
248
|
+
"noise",
|
|
249
|
+
"starfield"
|
|
215
250
|
].includes(p.preset) ? p.preset : "aurora",
|
|
216
251
|
speed: clamp(p.speed, 0, 2, .3),
|
|
217
252
|
scale: clamp(p.scale, .3, 3, 1),
|
|
@@ -222,7 +257,10 @@ function normalizeGeneratedBg(p) {
|
|
|
222
257
|
preset: [
|
|
223
258
|
"dots",
|
|
224
259
|
"waves",
|
|
225
|
-
"poly"
|
|
260
|
+
"poly",
|
|
261
|
+
"rain",
|
|
262
|
+
"contour",
|
|
263
|
+
"meta"
|
|
226
264
|
].includes(p.preset) ? p.preset : "dots",
|
|
227
265
|
density: clamp(p.density, 0, 1, .5),
|
|
228
266
|
scale: clamp(p.scale, .3, 3, 1),
|
|
@@ -230,6 +268,101 @@ function normalizeGeneratedBg(p) {
|
|
|
230
268
|
};
|
|
231
269
|
return null;
|
|
232
270
|
}
|
|
271
|
+
const MAX_PROFILES = 20;
|
|
272
|
+
const MAX_ROTATION_ITEMS = 30;
|
|
273
|
+
const MAX_THUMB_BYTES = 65536;
|
|
274
|
+
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
275
|
+
/** Coerce an unknown value into a ProfileAppearance (appearance subset only). */
|
|
276
|
+
function normalizeProfileAppearance(raw) {
|
|
277
|
+
const a = raw ?? {};
|
|
278
|
+
const c = a.color;
|
|
279
|
+
const ops = a.opacities ?? {};
|
|
280
|
+
const bl = a.blurs ?? {};
|
|
281
|
+
const blurs = {};
|
|
282
|
+
for (const k of [
|
|
283
|
+
"bg",
|
|
284
|
+
"sidebar",
|
|
285
|
+
"card",
|
|
286
|
+
"settings",
|
|
287
|
+
"chat",
|
|
288
|
+
"trajectory",
|
|
289
|
+
"input"
|
|
290
|
+
]) blurs[k] = clamp(bl[k], 0, 60, DEFAULT_CONFIG.blurs[k]);
|
|
291
|
+
return {
|
|
292
|
+
color: Array.isArray(c) && c.length === 3 && c.every((x) => typeof x === "number" && isFinite(x)) ? [
|
|
293
|
+
clamp(c[0], 0, 360, 220),
|
|
294
|
+
clamp(c[1], 0, 1, .55),
|
|
295
|
+
clamp(c[2], 0, 1, .25)
|
|
296
|
+
] : null,
|
|
297
|
+
opacities: {
|
|
298
|
+
bg: clamp(ops.bg, 0, 1, DEFAULT_CONFIG.opacities.bg),
|
|
299
|
+
sidebar: clamp(ops.sidebar, 0, 1, DEFAULT_CONFIG.opacities.sidebar),
|
|
300
|
+
card: clamp(ops.card, 0, 1, DEFAULT_CONFIG.opacities.card),
|
|
301
|
+
input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
|
|
302
|
+
},
|
|
303
|
+
blurs,
|
|
304
|
+
settingsOpacity: clamp(a.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
|
|
305
|
+
wallpaperOpacity: clamp(a.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
|
|
306
|
+
blur: clamp(a.blur, 0, 60, DEFAULT_CONFIG.blur),
|
|
307
|
+
chatTextOpacity: clamp(a.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
|
|
308
|
+
trajectoryOpacity: clamp(a.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function normalizeProfiles(raw) {
|
|
312
|
+
if (!Array.isArray(raw)) return [];
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const item of raw.slice(0, MAX_PROFILES)) {
|
|
315
|
+
const p = item ?? {};
|
|
316
|
+
if (typeof p.id !== "string" || p.id.length === 0 || p.id.length > 64) continue;
|
|
317
|
+
if (out.some((e) => e.id === p.id)) continue;
|
|
318
|
+
out.push({
|
|
319
|
+
id: p.id,
|
|
320
|
+
name: typeof p.name === "string" && p.name.trim() ? p.name.slice(0, 60) : "Profile",
|
|
321
|
+
createdAt: typeof p.createdAt === "string" ? p.createdAt : "",
|
|
322
|
+
config: normalizeProfileAppearance(p.config)
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/** Rotation items live as files under the rotation dir; only the server
|
|
328
|
+
* creates those names, so a stored `file` is accepted only when it is a bare
|
|
329
|
+
* filename with a known image extension (no path traversal). */
|
|
330
|
+
function safeRotationFile(name) {
|
|
331
|
+
if (typeof name !== "string" || !/^[\w-]+\.(jpg|jpeg|png|gif|webp)$/i.test(name)) return null;
|
|
332
|
+
return name;
|
|
333
|
+
}
|
|
334
|
+
function normalizeRotation(raw) {
|
|
335
|
+
const r = raw ?? {};
|
|
336
|
+
const items = [];
|
|
337
|
+
if (Array.isArray(r.items)) for (const item of r.items.slice(0, MAX_ROTATION_ITEMS)) {
|
|
338
|
+
const it = item ?? {};
|
|
339
|
+
const file = safeRotationFile(it.file);
|
|
340
|
+
if (file === null) continue;
|
|
341
|
+
items.push({
|
|
342
|
+
file,
|
|
343
|
+
thumb: typeof it.thumb === "string" && it.thumb.startsWith("data:image/") && it.thumb.length <= MAX_THUMB_BYTES ? it.thumb : ""
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
enabled: r.enabled === true,
|
|
348
|
+
mode: r.mode === "order" ? "order" : "shuffle",
|
|
349
|
+
interval: r.interval === "reload" || r.interval === "weekly" ? r.interval : "daily",
|
|
350
|
+
current: typeof r.current === "number" && isFinite(r.current) && r.current >= 0 ? Math.floor(r.current) : 0,
|
|
351
|
+
items,
|
|
352
|
+
lastRotate: typeof r.lastRotate === "string" ? r.lastRotate : null
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function normalizeSchedule(raw) {
|
|
356
|
+
const r = raw ?? {};
|
|
357
|
+
return {
|
|
358
|
+
enabled: r.enabled === true,
|
|
359
|
+
mode: r.mode === "system" ? "system" : "time",
|
|
360
|
+
dayProfile: typeof r.dayProfile === "string" ? r.dayProfile : null,
|
|
361
|
+
nightProfile: typeof r.nightProfile === "string" ? r.nightProfile : null,
|
|
362
|
+
dayStart: typeof r.dayStart === "string" && HHMM_RE.test(r.dayStart) ? r.dayStart : DEFAULT_CONFIG.schedule.dayStart,
|
|
363
|
+
nightStart: typeof r.nightStart === "string" && HHMM_RE.test(r.nightStart) ? r.nightStart : DEFAULT_CONFIG.schedule.nightStart
|
|
364
|
+
};
|
|
365
|
+
}
|
|
233
366
|
async function ensureDir() {
|
|
234
367
|
try {
|
|
235
368
|
await mkdir(dataDir(), { recursive: true });
|
|
@@ -256,9 +389,12 @@ async function writeConfig(config) {
|
|
|
256
389
|
return false;
|
|
257
390
|
}
|
|
258
391
|
}
|
|
259
|
-
|
|
392
|
+
/** The wallpaper slot is served over HTTP (never shipped as base64 inside the
|
|
393
|
+
* read RPC): the browser decodes it natively through the same pipeline as any
|
|
394
|
+
* <img>, so boot only transfers a tiny URL instead of the whole image. */
|
|
395
|
+
async function wallpaperServeUrl() {
|
|
260
396
|
try {
|
|
261
|
-
return
|
|
397
|
+
return (await stat(wallpaperPath())).size > 0 ? WALLPAPER_ROUTE : null;
|
|
262
398
|
} catch {
|
|
263
399
|
return null;
|
|
264
400
|
}
|
|
@@ -289,15 +425,16 @@ function sniffImageMime(buf) {
|
|
|
289
425
|
return "image/jpeg";
|
|
290
426
|
}
|
|
291
427
|
/** Download a wallpaper from a network URL and persist it into the local
|
|
292
|
-
* wallpaper.jpg slot (replacing whatever was stored), so type switches
|
|
293
|
-
*
|
|
294
|
-
*
|
|
428
|
+
* wallpaper.jpg slot (replacing whatever was stored), so type switches and
|
|
429
|
+
* rotation keep working through the single active slot. The response carries
|
|
430
|
+
* the serve URL, never the bytes. null removes the wallpaper.
|
|
431
|
+
* Returns { ok, wallpaperUrl?, error? }. */
|
|
295
432
|
async function writeWallpaperFromUrl(url) {
|
|
296
433
|
if (url === null) {
|
|
297
434
|
const ok = await writeWallpaper(null);
|
|
298
435
|
return {
|
|
299
436
|
ok,
|
|
300
|
-
|
|
437
|
+
wallpaperUrl: null,
|
|
301
438
|
error: ok ? void 0 : "remove failed"
|
|
302
439
|
};
|
|
303
440
|
}
|
|
@@ -359,18 +496,294 @@ async function writeWallpaperFromUrl(url) {
|
|
|
359
496
|
error: "read failed"
|
|
360
497
|
};
|
|
361
498
|
}
|
|
362
|
-
|
|
363
|
-
|
|
499
|
+
await ensureDir();
|
|
500
|
+
try {
|
|
501
|
+
await writeFile(wallpaperPath(), buf);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
console.error("dsh-any-background: failed to write the downloaded wallpaper", e);
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
error: "write failed"
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
ok: true,
|
|
511
|
+
wallpaperUrl: WALLPAPER_ROUTE
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const rotationDir = () => dshHomePath(DATA_DIR, ROTATION_DIR);
|
|
515
|
+
async function ensureRotationDir() {
|
|
516
|
+
try {
|
|
517
|
+
await mkdir(rotationDir(), { recursive: true });
|
|
518
|
+
} catch {}
|
|
519
|
+
}
|
|
520
|
+
function imageExtFor(mime) {
|
|
521
|
+
if (mime === "image/png") return "png";
|
|
522
|
+
if (mime === "image/gif") return "gif";
|
|
523
|
+
if (mime === "image/webp") return "webp";
|
|
524
|
+
return "jpg";
|
|
525
|
+
}
|
|
526
|
+
/** Accept only an inline base64 image data URL (same fence as writeWallpaper). */
|
|
527
|
+
function decodeImageDataUrl(dataUrl) {
|
|
528
|
+
if (typeof dataUrl !== "string") return null;
|
|
529
|
+
const m = /^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
530
|
+
if (!m) return null;
|
|
531
|
+
const buf = Buffer.from(m[1], "base64");
|
|
532
|
+
return buf.length > 0 ? buf : null;
|
|
533
|
+
}
|
|
534
|
+
/** Persist a thumbnail string only when it is a small inline image data URL. */
|
|
535
|
+
function sanitizeThumb(thumb) {
|
|
536
|
+
return typeof thumb === "string" && thumb.startsWith("data:image/") && thumb.length <= MAX_THUMB_BYTES ? thumb : "";
|
|
537
|
+
}
|
|
538
|
+
async function handleRotationAdd(payload) {
|
|
539
|
+
const buf = decodeImageDataUrl(payload?.dataUrl);
|
|
540
|
+
if (buf === null) return {
|
|
541
|
+
ok: false,
|
|
542
|
+
error: "invalid image"
|
|
543
|
+
};
|
|
544
|
+
const thumb = sanitizeThumb(payload?.thumb);
|
|
545
|
+
await ensureDir();
|
|
546
|
+
await ensureRotationDir();
|
|
547
|
+
if ((await readConfig()).rotation.items.length >= MAX_ROTATION_ITEMS) return {
|
|
548
|
+
ok: false,
|
|
549
|
+
error: "too many items"
|
|
550
|
+
};
|
|
551
|
+
const file = `wp-${Date.now().toString(36)}.${imageExtFor(sniffImageMime(buf))}`;
|
|
552
|
+
try {
|
|
553
|
+
await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, file), buf);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
console.error("dsh-any-background: failed to write a rotation wallpaper", e);
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
error: "write failed"
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const fresh = await readConfig();
|
|
562
|
+
fresh.rotation.items.push({
|
|
563
|
+
file,
|
|
564
|
+
thumb
|
|
565
|
+
});
|
|
566
|
+
if (!await writeConfig(fresh)) return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: "config write failed"
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
index: fresh.rotation.items.length - 1,
|
|
573
|
+
items: fresh.rotation.items
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
async function handleRotationRemove(payload) {
|
|
577
|
+
const idx = payload?.index;
|
|
578
|
+
if (typeof idx !== "number" || !isFinite(idx)) return {
|
|
579
|
+
ok: false,
|
|
580
|
+
error: "invalid index"
|
|
581
|
+
};
|
|
582
|
+
const cfg = await readConfig();
|
|
583
|
+
const i = Math.floor(idx);
|
|
584
|
+
if (i < 0 || i >= cfg.rotation.items.length) return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: "not found"
|
|
587
|
+
};
|
|
588
|
+
const [removed] = cfg.rotation.items.splice(i, 1);
|
|
589
|
+
cfg.rotation.current = Math.max(0, Math.min(cfg.rotation.current >= i ? cfg.rotation.current - 1 : cfg.rotation.current, Math.max(0, cfg.rotation.items.length - 1)));
|
|
590
|
+
if (removed !== void 0) try {
|
|
591
|
+
await rm(dshHomePath(DATA_DIR, ROTATION_DIR, removed.file), { force: true });
|
|
592
|
+
} catch {}
|
|
593
|
+
if (!await writeConfig(cfg)) return {
|
|
594
|
+
ok: false,
|
|
595
|
+
error: "config write failed"
|
|
596
|
+
};
|
|
597
|
+
return {
|
|
364
598
|
ok: true,
|
|
365
|
-
|
|
366
|
-
}
|
|
599
|
+
items: cfg.rotation.items
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
/** Activate a rotation item: copy its bytes over the active wallpaper slot and
|
|
603
|
+
* return the serve URL so the client applies it live (bytes never round-trip
|
|
604
|
+
* through the RPC response). */
|
|
605
|
+
async function handleRotationSet(payload) {
|
|
606
|
+
const idx = payload?.index;
|
|
607
|
+
if (typeof idx !== "number" || !isFinite(idx)) return {
|
|
367
608
|
ok: false,
|
|
368
|
-
error: "
|
|
609
|
+
error: "invalid index"
|
|
610
|
+
};
|
|
611
|
+
const cfg = await readConfig();
|
|
612
|
+
const i = Math.floor(idx);
|
|
613
|
+
const item = cfg.rotation.items[i];
|
|
614
|
+
if (item === void 0) return {
|
|
615
|
+
ok: false,
|
|
616
|
+
error: "not found"
|
|
617
|
+
};
|
|
618
|
+
let buf;
|
|
619
|
+
try {
|
|
620
|
+
buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
|
|
621
|
+
} catch {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
error: "file missing"
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
await writeFile(wallpaperPath(), buf);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
console.error("dsh-any-background: failed to activate a rotation wallpaper", e);
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
error: "write failed"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
ok: true,
|
|
638
|
+
wallpaperUrl: WALLPAPER_ROUTE
|
|
369
639
|
};
|
|
370
640
|
}
|
|
371
641
|
async function videoUrl() {
|
|
372
642
|
return await findVideoFile() ? VIDEO_ROUTE : null;
|
|
373
643
|
}
|
|
644
|
+
/** Guess a video MIME from the URL's path extension (fallback for servers that
|
|
645
|
+
* send no precise Content-Type). */
|
|
646
|
+
function videoMimeFromUrl(u) {
|
|
647
|
+
const p = u.pathname.toLowerCase();
|
|
648
|
+
if (/\.(mp4|m4v)$/.test(p)) return "video/mp4";
|
|
649
|
+
if (/\.webm$/.test(p)) return "video/webm";
|
|
650
|
+
if (/\.(ogg|ogv)$/.test(p)) return "video/ogg";
|
|
651
|
+
if (/\.(mov|qt)$/.test(p)) return "video/quicktime";
|
|
652
|
+
if (/\.(mkv|mk3d|mka)$/.test(p)) return "video/x-matroska";
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
/** Download a background video from a network URL and store it in the video
|
|
656
|
+
* slot (streamed to a temp file — never buffered whole), then record the MIME
|
|
657
|
+
* in the config so findVideoFile/serve resolve immediately. */
|
|
658
|
+
async function writeVideoFromUrl(url) {
|
|
659
|
+
if (url === null) return {
|
|
660
|
+
ok: false,
|
|
661
|
+
error: "invalid url"
|
|
662
|
+
};
|
|
663
|
+
let u;
|
|
664
|
+
try {
|
|
665
|
+
u = new URL(url);
|
|
666
|
+
} catch {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: "invalid url"
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return {
|
|
673
|
+
ok: false,
|
|
674
|
+
error: "unsupported scheme"
|
|
675
|
+
};
|
|
676
|
+
let res;
|
|
677
|
+
try {
|
|
678
|
+
const ctl = new AbortController();
|
|
679
|
+
const timer = setTimeout(() => ctl.abort(), VIDEO_FETCH_TIMEOUT);
|
|
680
|
+
try {
|
|
681
|
+
res = await fetch(url, {
|
|
682
|
+
redirect: "follow",
|
|
683
|
+
signal: ctl.signal
|
|
684
|
+
});
|
|
685
|
+
} finally {
|
|
686
|
+
clearTimeout(timer);
|
|
687
|
+
}
|
|
688
|
+
} catch (e) {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
error: e instanceof Error && e.name === "AbortError" ? "timeout" : "network error"
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (!res.ok) return {
|
|
695
|
+
ok: false,
|
|
696
|
+
error: `http ${res.status}`
|
|
697
|
+
};
|
|
698
|
+
let mime = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
699
|
+
if (mime === "" || mime === "application/octet-stream" || mime === "binary/octet-stream") mime = videoMimeFromUrl(u) ?? "video/mp4";
|
|
700
|
+
if (!mime.startsWith("video/")) return {
|
|
701
|
+
ok: false,
|
|
702
|
+
error: "not a video"
|
|
703
|
+
};
|
|
704
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
705
|
+
if (Number.isFinite(declared) && declared > VIDEO_FETCH_MAX) return {
|
|
706
|
+
ok: false,
|
|
707
|
+
error: "too large"
|
|
708
|
+
};
|
|
709
|
+
await ensureDir();
|
|
710
|
+
const tmp = dshHomePath(DATA_DIR, "video.download.tmp");
|
|
711
|
+
const target = videoPathFor(mime);
|
|
712
|
+
try {
|
|
713
|
+
if (res.body === null) return {
|
|
714
|
+
ok: false,
|
|
715
|
+
error: "empty response"
|
|
716
|
+
};
|
|
717
|
+
const out = createWriteStream(tmp);
|
|
718
|
+
let received = 0;
|
|
719
|
+
let failed = false;
|
|
720
|
+
const fail = () => {
|
|
721
|
+
if (failed) return;
|
|
722
|
+
failed = true;
|
|
723
|
+
out.destroy();
|
|
724
|
+
rm(tmp, { force: true });
|
|
725
|
+
};
|
|
726
|
+
const nodeStream = Readable.fromWeb(res.body);
|
|
727
|
+
let idle = null;
|
|
728
|
+
const pokeIdle = () => {
|
|
729
|
+
if (idle) clearTimeout(idle);
|
|
730
|
+
idle = setTimeout(() => {
|
|
731
|
+
nodeStream.destroy();
|
|
732
|
+
fail();
|
|
733
|
+
}, VIDEO_FETCH_IDLE_TIMEOUT);
|
|
734
|
+
};
|
|
735
|
+
nodeStream.on("data", (chunk) => {
|
|
736
|
+
pokeIdle();
|
|
737
|
+
received += chunk.byteLength;
|
|
738
|
+
if (received > VIDEO_FETCH_MAX) {
|
|
739
|
+
nodeStream.destroy();
|
|
740
|
+
fail();
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
nodeStream.on("end", () => {
|
|
744
|
+
if (idle) clearTimeout(idle);
|
|
745
|
+
idle = null;
|
|
746
|
+
});
|
|
747
|
+
nodeStream.on("aborted", fail);
|
|
748
|
+
nodeStream.on("error", fail);
|
|
749
|
+
out.on("error", fail);
|
|
750
|
+
pokeIdle();
|
|
751
|
+
await new Promise((resolve, reject) => {
|
|
752
|
+
nodeStream.pipe(out);
|
|
753
|
+
out.on("finish", () => resolve());
|
|
754
|
+
out.on("close", () => {
|
|
755
|
+
if (failed) reject(/* @__PURE__ */ new Error("download failed"));
|
|
756
|
+
});
|
|
757
|
+
nodeStream.on("error", () => reject(/* @__PURE__ */ new Error("download failed")));
|
|
758
|
+
});
|
|
759
|
+
if (failed) return {
|
|
760
|
+
ok: false,
|
|
761
|
+
error: "read failed"
|
|
762
|
+
};
|
|
763
|
+
for (const name of VIDEO_CANDIDATES) {
|
|
764
|
+
const p = dshHomePath(DATA_DIR, name);
|
|
765
|
+
if (p !== target) await rm(p, { force: true });
|
|
766
|
+
}
|
|
767
|
+
await rm(target, { force: true });
|
|
768
|
+
await rename(tmp, target);
|
|
769
|
+
const cfg = await readConfig();
|
|
770
|
+
if (cfg.videoMime !== mime) {
|
|
771
|
+
cfg.videoMime = mime;
|
|
772
|
+
await writeConfig(cfg);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
ok: true,
|
|
776
|
+
mime
|
|
777
|
+
};
|
|
778
|
+
} catch (e) {
|
|
779
|
+
console.error("dsh-any-background: failed to download the background video", e);
|
|
780
|
+
rm(tmp, { force: true });
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
error: e instanceof Error && e.message === "download failed" ? "read failed" : "write failed"
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
374
787
|
/** Persist a video from a data URL (null removes every variant); only used
|
|
375
788
|
* for removal and small legacy/import payloads. */
|
|
376
789
|
async function writeVideo(dataUrl) {
|
|
@@ -488,9 +901,10 @@ async function handleVideoUpload(req, res) {
|
|
|
488
901
|
}
|
|
489
902
|
try {
|
|
490
903
|
await ensureDir();
|
|
491
|
-
const tmp = dshHomePath(DATA_DIR,
|
|
904
|
+
const tmp = dshHomePath(DATA_DIR, VIDEO_UPLOAD_TMP);
|
|
492
905
|
const target = videoPathFor(mime);
|
|
493
906
|
const out = createWriteStream(tmp);
|
|
907
|
+
let received = 0;
|
|
494
908
|
let failed = false;
|
|
495
909
|
const fail = () => {
|
|
496
910
|
if (failed) return;
|
|
@@ -500,6 +914,13 @@ async function handleVideoUpload(req, res) {
|
|
|
500
914
|
};
|
|
501
915
|
req.on("aborted", fail);
|
|
502
916
|
req.on("error", fail);
|
|
917
|
+
req.on("data", (chunk) => {
|
|
918
|
+
received += chunk.byteLength;
|
|
919
|
+
if (received > VIDEO_UPLOAD_MAX) {
|
|
920
|
+
req.destroy();
|
|
921
|
+
fail();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
503
924
|
out.on("error", () => {
|
|
504
925
|
fail();
|
|
505
926
|
try {
|
|
@@ -517,6 +938,11 @@ async function handleVideoUpload(req, res) {
|
|
|
517
938
|
}
|
|
518
939
|
await rm(target, { force: true });
|
|
519
940
|
await rename(tmp, target);
|
|
941
|
+
const cfg = await readConfig();
|
|
942
|
+
if (cfg.videoMime !== mime) {
|
|
943
|
+
cfg.videoMime = mime;
|
|
944
|
+
await writeConfig(cfg);
|
|
945
|
+
}
|
|
520
946
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
521
947
|
res.end(JSON.stringify({ ok: true }));
|
|
522
948
|
} catch (e) {
|
|
@@ -536,23 +962,195 @@ async function handleVideoUpload(req, res) {
|
|
|
536
962
|
} catch {}
|
|
537
963
|
}
|
|
538
964
|
}
|
|
965
|
+
/** Stream the stored wallpaper: sniffed MIME, no caching (uploads and rotation
|
|
966
|
+
* replace the file in place). */
|
|
967
|
+
async function serveWallpaper(req, res) {
|
|
968
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
969
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
970
|
+
res.end(JSON.stringify({
|
|
971
|
+
ok: false,
|
|
972
|
+
error: "wallpaper route only serves GET/HEAD"
|
|
973
|
+
}));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
const st = await stat(wallpaperPath());
|
|
978
|
+
if (st.size === 0) {
|
|
979
|
+
res.writeHead(404);
|
|
980
|
+
res.end();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const mime = sniffImageMime(await new Promise((resolve, reject) => {
|
|
984
|
+
const chunks = [];
|
|
985
|
+
const s = createReadStream(wallpaperPath(), {
|
|
986
|
+
start: 0,
|
|
987
|
+
end: 15
|
|
988
|
+
});
|
|
989
|
+
s.on("data", (c) => chunks.push(c));
|
|
990
|
+
s.on("end", () => resolve(Buffer.concat(chunks)));
|
|
991
|
+
s.on("error", reject);
|
|
992
|
+
}));
|
|
993
|
+
res.writeHead(200, {
|
|
994
|
+
"Content-Type": mime,
|
|
995
|
+
"Content-Length": st.size,
|
|
996
|
+
"Cache-Control": "no-store"
|
|
997
|
+
});
|
|
998
|
+
if (req.method === "HEAD") {
|
|
999
|
+
res.end();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
createReadStream(wallpaperPath()).pipe(res);
|
|
1003
|
+
} catch {
|
|
1004
|
+
res.writeHead(404);
|
|
1005
|
+
res.end("no wallpaper stored");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/** Accept a raw wallpaper upload (POST): pipe the body straight into the
|
|
1009
|
+
* wallpaper slot — no base64 inflation, original pixels preserved. */
|
|
1010
|
+
async function handleWallpaperUpload(req, res) {
|
|
1011
|
+
if (req.method !== "POST") {
|
|
1012
|
+
res.writeHead(405);
|
|
1013
|
+
res.end();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (!(typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "").split(";")[0].trim().startsWith("image/")) {
|
|
1017
|
+
req.resume();
|
|
1018
|
+
res.writeHead(415, { "Content-Type": "application/json" });
|
|
1019
|
+
res.end(JSON.stringify({
|
|
1020
|
+
ok: false,
|
|
1021
|
+
error: "unsupported media type, expected image/*"
|
|
1022
|
+
}));
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
await ensureDir();
|
|
1027
|
+
const tmp = dshHomePath(DATA_DIR, UPLOAD_TMP);
|
|
1028
|
+
const out = createWriteStream(tmp);
|
|
1029
|
+
let received = 0;
|
|
1030
|
+
let failed = false;
|
|
1031
|
+
const fail = () => {
|
|
1032
|
+
if (failed) return;
|
|
1033
|
+
failed = true;
|
|
1034
|
+
out.destroy();
|
|
1035
|
+
rm(tmp, { force: true });
|
|
1036
|
+
};
|
|
1037
|
+
req.on("aborted", fail);
|
|
1038
|
+
req.on("error", fail);
|
|
1039
|
+
out.on("error", () => {
|
|
1040
|
+
fail();
|
|
1041
|
+
try {
|
|
1042
|
+
res.writeHead(500);
|
|
1043
|
+
res.end();
|
|
1044
|
+
} catch {}
|
|
1045
|
+
});
|
|
1046
|
+
req.on("data", (chunk) => {
|
|
1047
|
+
received += chunk.byteLength;
|
|
1048
|
+
if (received > WALLPAPER_UPLOAD_MAX) {
|
|
1049
|
+
req.destroy();
|
|
1050
|
+
fail();
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
req.pipe(out);
|
|
1054
|
+
out.on("finish", async () => {
|
|
1055
|
+
if (failed) return;
|
|
1056
|
+
try {
|
|
1057
|
+
if (received === 0) {
|
|
1058
|
+
fail();
|
|
1059
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1060
|
+
res.end(JSON.stringify({
|
|
1061
|
+
ok: false,
|
|
1062
|
+
error: "empty upload"
|
|
1063
|
+
}));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
await rm(wallpaperPath(), { force: true });
|
|
1067
|
+
await rename(tmp, wallpaperPath());
|
|
1068
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1069
|
+
res.end(JSON.stringify({
|
|
1070
|
+
ok: true,
|
|
1071
|
+
wallpaperUrl: WALLPAPER_ROUTE
|
|
1072
|
+
}));
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
console.error("dsh-any-background: failed to finalize the wallpaper upload", e);
|
|
1075
|
+
rm(tmp, { force: true });
|
|
1076
|
+
try {
|
|
1077
|
+
res.writeHead(500);
|
|
1078
|
+
res.end();
|
|
1079
|
+
} catch {}
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
console.error("dsh-any-background: failed to accept the wallpaper upload", e);
|
|
1084
|
+
try {
|
|
1085
|
+
res.writeHead(500);
|
|
1086
|
+
res.end();
|
|
1087
|
+
} catch {}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
539
1090
|
const NS = "dshAnyBackground";
|
|
540
1091
|
const RPC_CHANNEL = "/dsh-any-background";
|
|
541
1092
|
const RPC_BODY_MAX = 314572800;
|
|
1093
|
+
/** ISO week key — mirrors the client's rotationDue so daily/weekly cadence
|
|
1094
|
+
* decisions agree across both halves. */
|
|
1095
|
+
function isoWeekKey(d) {
|
|
1096
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
1097
|
+
const day = t.getUTCDay() || 7;
|
|
1098
|
+
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
1099
|
+
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
1100
|
+
const week = Math.ceil(((t.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
1101
|
+
return `${t.getUTCFullYear()}-W${week}`;
|
|
1102
|
+
}
|
|
1103
|
+
/** Advance the rotation pool when its cadence is due: pick the next item,
|
|
1104
|
+
* copy it over the active wallpaper slot, and persist the rotation state.
|
|
1105
|
+
* Runs inside `read` so a reload restores the NEW wallpaper directly — the
|
|
1106
|
+
* old one never reaches the screen. The client's maybeRotate stays as a
|
|
1107
|
+
* fallback and skips when this already advanced (the `rotated` flag). */
|
|
1108
|
+
async function advanceRotationIfDue() {
|
|
1109
|
+
const cfg = await readConfig();
|
|
1110
|
+
const rot = cfg.rotation;
|
|
1111
|
+
if (!rot.enabled || rot.items.length === 0) return false;
|
|
1112
|
+
const now = /* @__PURE__ */ new Date();
|
|
1113
|
+
const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
|
|
1114
|
+
if (!(rot.interval === "reload" || last === null || isNaN(last.getTime()) || (rot.interval === "daily" ? last.toDateString() !== now.toDateString() : isoWeekKey(last) !== isoWeekKey(now)))) return false;
|
|
1115
|
+
const n = rot.items.length;
|
|
1116
|
+
let idx = rot.current;
|
|
1117
|
+
if (rot.mode === "shuffle" && n > 1) while (idx === rot.current) idx = Math.floor(Math.random() * n);
|
|
1118
|
+
else idx = (rot.current + 1) % n;
|
|
1119
|
+
const item = rot.items[idx];
|
|
1120
|
+
if (item === void 0) return false;
|
|
1121
|
+
try {
|
|
1122
|
+
const buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
|
|
1123
|
+
await writeFile(wallpaperPath(), buf);
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
console.error("dsh-any-background: failed to advance the rotation pool", e);
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
cfg.rotation = {
|
|
1129
|
+
...rot,
|
|
1130
|
+
current: idx,
|
|
1131
|
+
lastRotate: now.toISOString()
|
|
1132
|
+
};
|
|
1133
|
+
if (!await writeConfig(cfg)) return false;
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
542
1136
|
/** Dispatch one decoded RPC method to the matching persistence routine and
|
|
543
1137
|
* return the wire `result` half of the server-response envelope. */
|
|
544
1138
|
async function handleRpcMethod(endpoint, payload) {
|
|
545
1139
|
const method = endpoint.slice(`${NS}/`.length);
|
|
546
1140
|
try {
|
|
547
1141
|
switch (method) {
|
|
548
|
-
case "read":
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1142
|
+
case "read": {
|
|
1143
|
+
const rotated = await advanceRotationIfDue();
|
|
1144
|
+
return {
|
|
1145
|
+
ok: true,
|
|
1146
|
+
value: {
|
|
1147
|
+
config: await readConfig(),
|
|
1148
|
+
wallpaperUrl: await wallpaperServeUrl(),
|
|
1149
|
+
videoUrl: await videoUrl(),
|
|
1150
|
+
rotated
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
556
1154
|
case "writeConfig": return {
|
|
557
1155
|
ok: true,
|
|
558
1156
|
value: await writeConfig(payload?.config ?? {})
|
|
@@ -569,6 +1167,22 @@ async function handleRpcMethod(endpoint, payload) {
|
|
|
569
1167
|
ok: true,
|
|
570
1168
|
value: await writeWallpaperFromUrl(payload?.url ?? null)
|
|
571
1169
|
};
|
|
1170
|
+
case "setVideoUrl": return {
|
|
1171
|
+
ok: true,
|
|
1172
|
+
value: await writeVideoFromUrl(payload?.url ?? null)
|
|
1173
|
+
};
|
|
1174
|
+
case "rotationAdd": return {
|
|
1175
|
+
ok: true,
|
|
1176
|
+
value: await handleRotationAdd(payload)
|
|
1177
|
+
};
|
|
1178
|
+
case "rotationRemove": return {
|
|
1179
|
+
ok: true,
|
|
1180
|
+
value: await handleRotationRemove(payload)
|
|
1181
|
+
};
|
|
1182
|
+
case "rotationSet": return {
|
|
1183
|
+
ok: true,
|
|
1184
|
+
value: await handleRotationSet(payload)
|
|
1185
|
+
};
|
|
572
1186
|
default: return {
|
|
573
1187
|
ok: false,
|
|
574
1188
|
error: {
|
|
@@ -685,6 +1299,15 @@ function apply(ctx) {
|
|
|
685
1299
|
}));
|
|
686
1300
|
}
|
|
687
1301
|
}), "dsh-any-background: rpc channel");
|
|
1302
|
+
const fenceUpload = (handler) => (req, res) => {
|
|
1303
|
+
const rejection = webCtx.connection.requestRejection(req);
|
|
1304
|
+
if (rejection !== void 0) {
|
|
1305
|
+
res.writeHead(rejection);
|
|
1306
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
handler(req, res);
|
|
1310
|
+
};
|
|
688
1311
|
webCtx.effect(() => webCtx.webServer.register({
|
|
689
1312
|
kind: "prefix",
|
|
690
1313
|
path: VIDEO_ROUTE,
|
|
@@ -693,8 +1316,18 @@ function apply(ctx) {
|
|
|
693
1316
|
webCtx.effect(() => webCtx.webServer.register({
|
|
694
1317
|
kind: "exact",
|
|
695
1318
|
path: UPLOAD_ROUTE,
|
|
696
|
-
handler: handleVideoUpload
|
|
1319
|
+
handler: fenceUpload(handleVideoUpload)
|
|
697
1320
|
}), "dsh-any-background: upload route");
|
|
1321
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1322
|
+
kind: "prefix",
|
|
1323
|
+
path: WALLPAPER_ROUTE,
|
|
1324
|
+
handler: serveWallpaper
|
|
1325
|
+
}), "dsh-any-background: wallpaper route");
|
|
1326
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1327
|
+
kind: "exact",
|
|
1328
|
+
path: WALLPAPER_UPLOAD_ROUTE,
|
|
1329
|
+
handler: fenceUpload(handleWallpaperUpload)
|
|
1330
|
+
}), "dsh-any-background: wallpaper upload route");
|
|
698
1331
|
});
|
|
699
1332
|
}
|
|
700
1333
|
//#endregion
|