dsh-any-background 0.3.10 → 0.4.2
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 +11 -13
- package/README.zh.md +11 -13
- package/lib/client.js +872 -275
- package/lib/client.js.map +1 -1
- package/lib/index.js +327 -73
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, copyFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
4
|
import { Readable } from "node:stream";
|
|
4
5
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
@@ -249,6 +250,42 @@ async function resolveHostInfo(installAnchor) {
|
|
|
249
250
|
return cache;
|
|
250
251
|
}
|
|
251
252
|
//#endregion
|
|
253
|
+
//#region src/shared/rotation.ts
|
|
254
|
+
/** ISO week key (Monday-first). */
|
|
255
|
+
function isoWeekKey(d) {
|
|
256
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
257
|
+
const day = t.getUTCDay() || 7;
|
|
258
|
+
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
259
|
+
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
260
|
+
const week = Math.ceil(((t.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
261
|
+
return `${t.getUTCFullYear()}-W${week}`;
|
|
262
|
+
}
|
|
263
|
+
/** Whether the pool should advance right now. */
|
|
264
|
+
function rotationDue(rot, now = /* @__PURE__ */ new Date()) {
|
|
265
|
+
if (!rot.enabled || rot.items.length === 0) return false;
|
|
266
|
+
if (rot.interval === "reload") return true;
|
|
267
|
+
const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
|
|
268
|
+
if (last === null || isNaN(last.getTime())) return true;
|
|
269
|
+
if (rot.interval === "hourly") return hourKey(last) !== hourKey(now);
|
|
270
|
+
if (rot.interval === "daily") return last.toDateString() !== now.toDateString();
|
|
271
|
+
return isoWeekKey(last) !== isoWeekKey(now);
|
|
272
|
+
}
|
|
273
|
+
/** Epoch-hour bucket: hourly cadence advances on each full hour boundary
|
|
274
|
+
* (in whole-hour timezones — UTC+8 among them — that is exactly the local
|
|
275
|
+
* on-the-hour tick). */
|
|
276
|
+
const hourKey = (d) => Math.floor(d.getTime() / 36e5);
|
|
277
|
+
/** Pick the next pool index under the configured mode. */
|
|
278
|
+
function pickNextRotationIndex(rot) {
|
|
279
|
+
const n = rot.items.length;
|
|
280
|
+
if (n === 0) return -1;
|
|
281
|
+
if (rot.mode === "shuffle" && n > 1) {
|
|
282
|
+
let idx = rot.current;
|
|
283
|
+
while (idx === rot.current) idx = Math.floor(Math.random() * n);
|
|
284
|
+
return idx;
|
|
285
|
+
}
|
|
286
|
+
return (rot.current + 1) % n;
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
252
289
|
//#region src/index.ts
|
|
253
290
|
/**
|
|
254
291
|
* Node half of dsh-any-background: file-backed theme persistence.
|
|
@@ -276,9 +313,12 @@ const WALLPAPER_ROUTE = "/dsh-any-background/wallpaper";
|
|
|
276
313
|
const WALLPAPER_UPLOAD_ROUTE = "/dsh-any-background/wallpaper/upload";
|
|
277
314
|
const FONT_ROUTE = "/dsh-any-background/font";
|
|
278
315
|
const FONT_UPLOAD_ROUTE = "/dsh-any-background/font/upload";
|
|
316
|
+
/** Serves one rotation-pool image by name (the export path fetches these). */
|
|
317
|
+
const ROTATION_FILE_ROUTE = "/dsh-any-background/rotation/file";
|
|
279
318
|
const UPLOAD_TMP = "wallpaper.upload.tmp";
|
|
280
319
|
const VIDEO_UPLOAD_TMP = "video.upload.tmp";
|
|
281
320
|
const FONT_UPLOAD_TMP = "font.upload.tmp";
|
|
321
|
+
const VIDEO_DOWNLOAD_TMP = "video.download.tmp";
|
|
282
322
|
const WALLPAPER_UPLOAD_MAX = 104857600;
|
|
283
323
|
const VIDEO_UPLOAD_MAX = 2147483648;
|
|
284
324
|
const FONT_UPLOAD_MAX = 104857600;
|
|
@@ -305,6 +345,20 @@ const VIDEO_CANDIDATES = [
|
|
|
305
345
|
"wallpaper.mkv",
|
|
306
346
|
"wallpaper.video"
|
|
307
347
|
];
|
|
348
|
+
/** Reverse of videoFileName for the stray-adoption path below: a config that
|
|
349
|
+
* lost videoMime would otherwise serve the stored file as
|
|
350
|
+
* application/octet-stream, which no <video> element plays. The legacy
|
|
351
|
+
* extensionless name keeps null — its container is unknowable from the name. */
|
|
352
|
+
function mimeForVideoFile(name) {
|
|
353
|
+
switch (name) {
|
|
354
|
+
case "wallpaper.mp4": return "video/mp4";
|
|
355
|
+
case "wallpaper.webm": return "video/webm";
|
|
356
|
+
case "wallpaper.ogv": return "video/ogg";
|
|
357
|
+
case "wallpaper.mov": return "video/quicktime";
|
|
358
|
+
case "wallpaper.mkv": return "video/x-matroska";
|
|
359
|
+
default: return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
308
362
|
/** Font slot: one font owns the slot, named by format like the video slot. */
|
|
309
363
|
function fontFileName(mime) {
|
|
310
364
|
switch (mime) {
|
|
@@ -439,6 +493,7 @@ const DEFAULT_CONFIG = {
|
|
|
439
493
|
headerOpacity: .5,
|
|
440
494
|
headerBarOpacity: .5,
|
|
441
495
|
captionInk: "auto",
|
|
496
|
+
protectThirdParty: true,
|
|
442
497
|
profiles: [],
|
|
443
498
|
rotation: {
|
|
444
499
|
enabled: false,
|
|
@@ -472,6 +527,21 @@ const exists = async (p) => {
|
|
|
472
527
|
return false;
|
|
473
528
|
}
|
|
474
529
|
};
|
|
530
|
+
/** Read a file's first bytes for magic-number sniffing (null when unreadable). */
|
|
531
|
+
const readHead = async (p, n) => {
|
|
532
|
+
try {
|
|
533
|
+
const head = Buffer.alloc(n);
|
|
534
|
+
const fh = await open(p, "r");
|
|
535
|
+
try {
|
|
536
|
+
const { bytesRead } = await fh.read(head, 0, n, 0);
|
|
537
|
+
return bytesRead === 0 ? null : head.subarray(0, bytesRead);
|
|
538
|
+
} finally {
|
|
539
|
+
await fh.close();
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
475
545
|
/** Locate the stored video: the recorded MIME decides the expected name; a
|
|
476
546
|
* legacy extensionless wallpaper.video is renamed on first access. */
|
|
477
547
|
async function findVideoFile() {
|
|
@@ -493,6 +563,20 @@ async function findVideoFile() {
|
|
|
493
563
|
} catch {
|
|
494
564
|
return null;
|
|
495
565
|
}
|
|
566
|
+
if (cfg.videoMime === null) {
|
|
567
|
+
const mime = mimeForVideoFile(name);
|
|
568
|
+
if (mime !== null) {
|
|
569
|
+
const fresh = await readConfig();
|
|
570
|
+
if (fresh.videoMime !== mime) {
|
|
571
|
+
fresh.videoMime = mime;
|
|
572
|
+
await writeConfig(fresh);
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
path: p,
|
|
576
|
+
mime
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
496
580
|
return {
|
|
497
581
|
path: p,
|
|
498
582
|
mime: cfg.videoMime
|
|
@@ -503,40 +587,50 @@ async function findVideoFile() {
|
|
|
503
587
|
function clamp(n, lo, hi, def) {
|
|
504
588
|
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
505
589
|
}
|
|
506
|
-
/** Locate the stored font: the recorded MIME decides the expected name
|
|
507
|
-
*
|
|
508
|
-
*
|
|
590
|
+
/** Locate the stored font: the recorded MIME decides the expected name. A
|
|
591
|
+
* stray from a lost config write (or one whose recorded MIME no longer
|
|
592
|
+
* matches the bytes on disk) is adopted by sniffing the file's REAL container
|
|
593
|
+
* magic — never by trusting its name: the old name-based rename moved woff2
|
|
594
|
+
* bytes into font.ttf, and the next lookup then served them as font/ttf. The
|
|
595
|
+
* sniffed answer is renamed into place and persisted, so later lookups
|
|
596
|
+
* resolve by name (mirrors the video slot's recovery). */
|
|
509
597
|
async function findFontFile() {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
598
|
+
const cfg = await readConfig();
|
|
599
|
+
if (cfg.fontMime !== null) {
|
|
600
|
+
const expected = fontPathFor(cfg.fontMime);
|
|
601
|
+
if (await exists(expected)) return {
|
|
602
|
+
path: expected,
|
|
603
|
+
mime: cfg.fontMime
|
|
604
|
+
};
|
|
605
|
+
}
|
|
516
606
|
for (const name of FONT_CANDIDATES) {
|
|
517
607
|
const p = dshHomePath(DATA_DIR, name);
|
|
518
608
|
if (!await exists(p)) continue;
|
|
519
|
-
const
|
|
609
|
+
const head = await readHead(p, 4);
|
|
610
|
+
if (head === null) continue;
|
|
611
|
+
const sniffed = sniffFontMime(head);
|
|
612
|
+
if (sniffed === null) continue;
|
|
613
|
+
const target = fontPathFor(sniffed);
|
|
520
614
|
try {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
};
|
|
615
|
+
if (target !== p) {
|
|
616
|
+
await rm(target, { force: true });
|
|
617
|
+
await rename(p, target);
|
|
618
|
+
}
|
|
526
619
|
} catch {
|
|
527
620
|
return null;
|
|
528
621
|
}
|
|
622
|
+
const fresh = await readConfig();
|
|
623
|
+
if (fresh.fontMime !== sniffed) {
|
|
624
|
+
fresh.fontMime = sniffed;
|
|
625
|
+
await writeConfig(fresh);
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
path: target,
|
|
629
|
+
mime: sniffed
|
|
630
|
+
};
|
|
529
631
|
}
|
|
530
632
|
return null;
|
|
531
633
|
}
|
|
532
|
-
function mimeForFontFile(name) {
|
|
533
|
-
switch (name) {
|
|
534
|
-
case "font.woff2": return "font/woff2";
|
|
535
|
-
case "font.woff": return "font/woff";
|
|
536
|
-
case "font.otf": return "font/otf";
|
|
537
|
-
default: return "font/ttf";
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
634
|
async function fontUrl() {
|
|
541
635
|
return await findFontFile() ? FONT_ROUTE : null;
|
|
542
636
|
}
|
|
@@ -656,6 +750,7 @@ function normalizeConfig(raw) {
|
|
|
656
750
|
headerOpacity: clamp(r.headerOpacity, 0, 1, DEFAULT_CONFIG.headerOpacity),
|
|
657
751
|
headerBarOpacity: clamp(r.headerBarOpacity, 0, 1, DEFAULT_CONFIG.headerBarOpacity),
|
|
658
752
|
captionInk: r.captionInk === "theme" || r.captionInk === "white" || r.captionInk === "black" ? r.captionInk : "auto",
|
|
753
|
+
protectThirdParty: typeof r.protectThirdParty === "boolean" ? r.protectThirdParty : DEFAULT_CONFIG.protectThirdParty,
|
|
659
754
|
profiles: normalizeProfiles(r.profiles),
|
|
660
755
|
rotation: normalizeRotation(r.rotation),
|
|
661
756
|
schedule: normalizeSchedule(r.schedule),
|
|
@@ -785,7 +880,7 @@ function normalizeRotation(raw) {
|
|
|
785
880
|
return {
|
|
786
881
|
enabled: r.enabled === true,
|
|
787
882
|
mode: r.mode === "order" ? "order" : "shuffle",
|
|
788
|
-
interval: r.interval === "reload" || r.interval === "weekly" ? r.interval : "daily",
|
|
883
|
+
interval: r.interval === "reload" || r.interval === "hourly" || r.interval === "weekly" ? r.interval : "daily",
|
|
789
884
|
current: typeof r.current === "number" && isFinite(r.current) && r.current >= 0 ? Math.floor(r.current) : 0,
|
|
790
885
|
items,
|
|
791
886
|
lastRotate: typeof r.lastRotate === "string" ? r.lastRotate : null
|
|
@@ -802,9 +897,12 @@ function normalizeSchedule(raw) {
|
|
|
802
897
|
nightStart: typeof r.nightStart === "string" && HHMM_RE.test(r.nightStart) ? r.nightStart : DEFAULT_CONFIG.schedule.nightStart
|
|
803
898
|
};
|
|
804
899
|
}
|
|
900
|
+
let dirEnsured = false;
|
|
805
901
|
async function ensureDir() {
|
|
902
|
+
if (dirEnsured) return;
|
|
806
903
|
try {
|
|
807
904
|
await mkdir(dataDir(), { recursive: true });
|
|
905
|
+
dirEnsured = true;
|
|
808
906
|
} catch (e) {
|
|
809
907
|
console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
|
|
810
908
|
}
|
|
@@ -830,7 +928,7 @@ async function readConfig() {
|
|
|
830
928
|
text = await readFile(file, "utf8");
|
|
831
929
|
} catch {
|
|
832
930
|
dropConfigCache();
|
|
833
|
-
return
|
|
931
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
834
932
|
}
|
|
835
933
|
try {
|
|
836
934
|
const parsed = normalizeConfig(JSON.parse(text));
|
|
@@ -844,7 +942,7 @@ async function readConfig() {
|
|
|
844
942
|
console.warn(`dsh-any-background: theme-config.json was corrupt, archived to "${into}" and reset to defaults`, e);
|
|
845
943
|
} catch {}
|
|
846
944
|
dropConfigCache();
|
|
847
|
-
return
|
|
945
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
848
946
|
}
|
|
849
947
|
}
|
|
850
948
|
const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set(["opacity"]);
|
|
@@ -875,13 +973,13 @@ function warnUnknownConfigKeys(raw, normalized) {
|
|
|
875
973
|
}
|
|
876
974
|
async function writeConfig(config) {
|
|
877
975
|
await ensureDir();
|
|
976
|
+
dropConfigCache();
|
|
878
977
|
const tmp = `${configPath()}.tmp`;
|
|
879
978
|
try {
|
|
880
979
|
const normalized = normalizeConfig(config);
|
|
881
980
|
warnUnknownConfigKeys(config, normalized);
|
|
882
981
|
await writeFile(tmp, JSON.stringify(normalized, null, 2), "utf8");
|
|
883
982
|
await rename(tmp, configPath());
|
|
884
|
-
dropConfigCache();
|
|
885
983
|
return true;
|
|
886
984
|
} catch (e) {
|
|
887
985
|
console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
|
|
@@ -1065,7 +1163,7 @@ async function handleRotationAdd(payload) {
|
|
|
1065
1163
|
ok: false,
|
|
1066
1164
|
error: "too many items"
|
|
1067
1165
|
};
|
|
1068
|
-
const file = `wp-${Date.now().toString(36)}.${imageExtFor(sniffImageMime(buf))}`;
|
|
1166
|
+
const file = `wp-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}.${imageExtFor(sniffImageMime(buf))}`;
|
|
1069
1167
|
try {
|
|
1070
1168
|
await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, file), buf);
|
|
1071
1169
|
} catch (e) {
|
|
@@ -1132,27 +1230,99 @@ async function handleRotationSet(payload) {
|
|
|
1132
1230
|
ok: false,
|
|
1133
1231
|
error: "not found"
|
|
1134
1232
|
};
|
|
1135
|
-
let buf;
|
|
1136
1233
|
try {
|
|
1137
|
-
|
|
1138
|
-
} catch {
|
|
1234
|
+
await copyFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file), wallpaperPath());
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
console.error("dsh-any-background: failed to activate a rotation wallpaper", e);
|
|
1139
1237
|
return {
|
|
1140
1238
|
ok: false,
|
|
1141
|
-
error: "file missing"
|
|
1239
|
+
error: typeof e === "object" && e !== null && e.code === "ENOENT" ? "file missing" : "write failed"
|
|
1142
1240
|
};
|
|
1143
1241
|
}
|
|
1144
1242
|
try {
|
|
1145
|
-
await
|
|
1146
|
-
|
|
1147
|
-
|
|
1243
|
+
const fresh = await readConfig();
|
|
1244
|
+
if (fresh.rotation.items[i] !== void 0) {
|
|
1245
|
+
if (!await writeConfig({
|
|
1246
|
+
...fresh,
|
|
1247
|
+
rotation: {
|
|
1248
|
+
...fresh.rotation,
|
|
1249
|
+
current: i,
|
|
1250
|
+
lastRotate: fresh.rotation.lastRotate ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1251
|
+
}
|
|
1252
|
+
})) console.warn("dsh-any-background: rotation cursor did not persist (the client save will catch up)");
|
|
1253
|
+
}
|
|
1254
|
+
} catch {}
|
|
1255
|
+
return {
|
|
1256
|
+
ok: true,
|
|
1257
|
+
wallpaperUrl: WALLPAPER_ROUTE
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
/** Replace the whole rotation pool from an imported theme (v3 export): each
|
|
1261
|
+
* entry's bytes are written under the name the exported config's metadata
|
|
1262
|
+
* already points at, files the imported pool does not reference are dropped,
|
|
1263
|
+
* and the config's item list is rebuilt. Wholesale replacement matches import
|
|
1264
|
+
* semantics — the rotation state lands exactly as it left the source machine
|
|
1265
|
+
* (a v2 export could not carry pool bytes, so its entries pointed at files
|
|
1266
|
+
* that never travelled and every activation failed with 'file missing'). */
|
|
1267
|
+
async function handleRotationRestore(payload) {
|
|
1268
|
+
const raw = payload?.items;
|
|
1269
|
+
if (!Array.isArray(raw)) return {
|
|
1270
|
+
ok: false,
|
|
1271
|
+
error: "invalid payload"
|
|
1272
|
+
};
|
|
1273
|
+
const entries = [];
|
|
1274
|
+
for (const it of raw.slice(0, MAX_ROTATION_ITEMS)) {
|
|
1275
|
+
const e = it ?? {};
|
|
1276
|
+
const file = safeRotationFile(e.file);
|
|
1277
|
+
if (file === null) return {
|
|
1278
|
+
ok: false,
|
|
1279
|
+
error: "invalid file name"
|
|
1280
|
+
};
|
|
1281
|
+
if (entries.some((x) => x.file === file)) continue;
|
|
1282
|
+
const buf = decodeImageDataUrl(e.dataUrl);
|
|
1283
|
+
if (buf === null) return {
|
|
1284
|
+
ok: false,
|
|
1285
|
+
error: "invalid image"
|
|
1286
|
+
};
|
|
1287
|
+
entries.push({
|
|
1288
|
+
file,
|
|
1289
|
+
thumb: sanitizeThumb(e.thumb),
|
|
1290
|
+
buf
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
await ensureDir();
|
|
1294
|
+
await ensureRotationDir();
|
|
1295
|
+
if (await exists(rotationDir())) {
|
|
1296
|
+
for (const name of await readdir(rotationDir())) if (!entries.some((x) => x.file === name)) await rm(dshHomePath(DATA_DIR, ROTATION_DIR, name), { force: true });
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
for (const e of entries) await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, e.file), e.buf);
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
console.error("dsh-any-background: failed to restore the rotation pool", err);
|
|
1148
1302
|
return {
|
|
1149
1303
|
ok: false,
|
|
1150
1304
|
error: "write failed"
|
|
1151
1305
|
};
|
|
1152
1306
|
}
|
|
1307
|
+
const items = entries.map((e) => ({
|
|
1308
|
+
file: e.file,
|
|
1309
|
+
thumb: e.thumb
|
|
1310
|
+
}));
|
|
1311
|
+
const fresh = await readConfig();
|
|
1312
|
+
if (!await writeConfig({
|
|
1313
|
+
...fresh,
|
|
1314
|
+
rotation: {
|
|
1315
|
+
...fresh.rotation,
|
|
1316
|
+
current: Math.min(fresh.rotation.current, Math.max(0, items.length - 1)),
|
|
1317
|
+
items
|
|
1318
|
+
}
|
|
1319
|
+
})) return {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: "config write failed"
|
|
1322
|
+
};
|
|
1153
1323
|
return {
|
|
1154
1324
|
ok: true,
|
|
1155
|
-
|
|
1325
|
+
items
|
|
1156
1326
|
};
|
|
1157
1327
|
}
|
|
1158
1328
|
async function videoUrl() {
|
|
@@ -1224,7 +1394,7 @@ async function writeVideoFromUrl(url) {
|
|
|
1224
1394
|
error: "too large"
|
|
1225
1395
|
};
|
|
1226
1396
|
await ensureDir();
|
|
1227
|
-
const tmp = dshHomePath(DATA_DIR,
|
|
1397
|
+
const tmp = dshHomePath(DATA_DIR, VIDEO_DOWNLOAD_TMP);
|
|
1228
1398
|
const target = videoPathFor(mime);
|
|
1229
1399
|
try {
|
|
1230
1400
|
if (res.body === null) return {
|
|
@@ -1530,16 +1700,13 @@ async function serveWallpaper(req, res) {
|
|
|
1530
1700
|
res.end();
|
|
1531
1701
|
return;
|
|
1532
1702
|
}
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
s.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1541
|
-
s.on("error", reject);
|
|
1542
|
-
}));
|
|
1703
|
+
const head = await readHead(wallpaperPath(), 16);
|
|
1704
|
+
if (head === null) {
|
|
1705
|
+
res.writeHead(404);
|
|
1706
|
+
res.end("no wallpaper stored");
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const mime = sniffImageMime(head);
|
|
1543
1710
|
res.writeHead(200, {
|
|
1544
1711
|
"Content-Type": mime,
|
|
1545
1712
|
"Content-Length": st.size,
|
|
@@ -1611,6 +1778,16 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1611
1778
|
}));
|
|
1612
1779
|
return;
|
|
1613
1780
|
}
|
|
1781
|
+
const head = await readHead(tmp, 12);
|
|
1782
|
+
if (head === null || !isImageBytes(head)) {
|
|
1783
|
+
await rm(tmp, { force: true });
|
|
1784
|
+
res.writeHead(415, { "Content-Type": "application/json" });
|
|
1785
|
+
res.end(JSON.stringify({
|
|
1786
|
+
ok: false,
|
|
1787
|
+
error: "not an image"
|
|
1788
|
+
}));
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1614
1791
|
await rm(wallpaperPath(), { force: true });
|
|
1615
1792
|
await rename(tmp, wallpaperPath());
|
|
1616
1793
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1635,6 +1812,47 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1635
1812
|
} catch {}
|
|
1636
1813
|
}
|
|
1637
1814
|
}
|
|
1815
|
+
/** Serve one rotation-pool image by its `?f=<name>` query. The name goes
|
|
1816
|
+
* through the same pattern fence the config sanitizer uses (bare file name
|
|
1817
|
+
* with a known image extension), so there is no path traversal; unfenced like
|
|
1818
|
+
* the other image serve routes (an <img> fetch carries no auth headers). */
|
|
1819
|
+
async function serveRotationFile(req, res) {
|
|
1820
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1821
|
+
res.writeHead(405);
|
|
1822
|
+
res.end();
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
let name = null;
|
|
1826
|
+
try {
|
|
1827
|
+
name = safeRotationFile(new URL(req.url ?? "/", "http://localhost").searchParams.get("f"));
|
|
1828
|
+
} catch {
|
|
1829
|
+
name = null;
|
|
1830
|
+
}
|
|
1831
|
+
if (name === null) {
|
|
1832
|
+
res.writeHead(400);
|
|
1833
|
+
res.end("invalid file name");
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
const p = dshHomePath(DATA_DIR, ROTATION_DIR, name);
|
|
1838
|
+
const st = await stat(p);
|
|
1839
|
+
const head = await readHead(p, 12);
|
|
1840
|
+
if (head === null) throw new Error("unreadable");
|
|
1841
|
+
res.writeHead(200, {
|
|
1842
|
+
"Content-Type": sniffImageMime(head),
|
|
1843
|
+
"Content-Length": st.size,
|
|
1844
|
+
"Cache-Control": "no-store"
|
|
1845
|
+
});
|
|
1846
|
+
if (req.method === "HEAD") {
|
|
1847
|
+
res.end();
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
createReadStream(p).pipe(res);
|
|
1851
|
+
} catch {
|
|
1852
|
+
res.writeHead(404);
|
|
1853
|
+
res.end("no such rotation image");
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1638
1856
|
/** Stream the stored custom font: sniffed MIME, no caching (uploads replace
|
|
1639
1857
|
* the file in place). Fonts need no Range support — the browser fetches once. */
|
|
1640
1858
|
async function serveFont(req, res) {
|
|
@@ -1794,36 +2012,22 @@ async function removeFontFile() {
|
|
|
1794
2012
|
const NS = "dshAnyBackground";
|
|
1795
2013
|
const RPC_CHANNEL = "/dsh-any-background";
|
|
1796
2014
|
const RPC_BODY_MAX = 314572800;
|
|
1797
|
-
/** ISO week key — mirrors the client's rotationDue so daily/weekly cadence
|
|
1798
|
-
* decisions agree across both halves. */
|
|
1799
|
-
function isoWeekKey(d) {
|
|
1800
|
-
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
1801
|
-
const day = t.getUTCDay() || 7;
|
|
1802
|
-
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
1803
|
-
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
1804
|
-
const week = Math.ceil(((t.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
1805
|
-
return `${t.getUTCFullYear()}-W${week}`;
|
|
1806
|
-
}
|
|
1807
2015
|
/** Advance the rotation pool when its cadence is due: pick the next item,
|
|
1808
2016
|
* copy it over the active wallpaper slot, and persist the rotation state.
|
|
1809
2017
|
* Runs inside `read` so a reload restores the NEW wallpaper directly — the
|
|
1810
2018
|
* old one never reaches the screen. The client's maybeRotate stays as a
|
|
1811
|
-
* fallback and skips when this already advanced (the `rotated` flag).
|
|
2019
|
+
* fallback and skips when this already advanced (the `rotated` flag).
|
|
2020
|
+
* The due/next math is the SHARED implementation (`src/shared/rotation.ts`)
|
|
2021
|
+
* so both halves can never disagree on a day/week boundary. */
|
|
1812
2022
|
async function advanceRotationIfDue() {
|
|
1813
2023
|
const rot = (await readConfig()).rotation;
|
|
1814
|
-
if (!rot
|
|
2024
|
+
if (!rotationDue(rot)) return false;
|
|
1815
2025
|
const now = /* @__PURE__ */ new Date();
|
|
1816
|
-
const
|
|
1817
|
-
if (!(rot.interval === "reload" || last === null || isNaN(last.getTime()) || (rot.interval === "daily" ? last.toDateString() !== now.toDateString() : isoWeekKey(last) !== isoWeekKey(now)))) return false;
|
|
1818
|
-
const n = rot.items.length;
|
|
1819
|
-
let idx = rot.current;
|
|
1820
|
-
if (rot.mode === "shuffle" && n > 1) while (idx === rot.current) idx = Math.floor(Math.random() * n);
|
|
1821
|
-
else idx = (rot.current + 1) % n;
|
|
2026
|
+
const idx = pickNextRotationIndex(rot);
|
|
1822
2027
|
const item = rot.items[idx];
|
|
1823
2028
|
if (item === void 0) return false;
|
|
1824
2029
|
try {
|
|
1825
|
-
|
|
1826
|
-
await writeFile(wallpaperPath(), buf);
|
|
2030
|
+
await copyFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file), wallpaperPath());
|
|
1827
2031
|
} catch (e) {
|
|
1828
2032
|
console.error("dsh-any-background: failed to advance the rotation pool", e);
|
|
1829
2033
|
return false;
|
|
@@ -1848,17 +2052,22 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1848
2052
|
const rotated = await advanceRotationIfDue();
|
|
1849
2053
|
const config = await readConfig();
|
|
1850
2054
|
const firstRun = !await exists(configPath());
|
|
1851
|
-
|
|
2055
|
+
const [wpServeUrl, vidServeUrl, fntServeUrl, host] = await Promise.all([
|
|
2056
|
+
wallpaperServeUrl(),
|
|
2057
|
+
videoUrl(),
|
|
2058
|
+
fontUrl(),
|
|
2059
|
+
hostInfo
|
|
2060
|
+
]);
|
|
1852
2061
|
return {
|
|
1853
2062
|
ok: true,
|
|
1854
2063
|
value: {
|
|
1855
2064
|
config,
|
|
1856
|
-
wallpaperUrl:
|
|
1857
|
-
videoUrl:
|
|
1858
|
-
fontUrl:
|
|
2065
|
+
wallpaperUrl: wpServeUrl,
|
|
2066
|
+
videoUrl: vidServeUrl,
|
|
2067
|
+
fontUrl: fntServeUrl,
|
|
1859
2068
|
rotated,
|
|
1860
2069
|
firstRun,
|
|
1861
|
-
host
|
|
2070
|
+
host
|
|
1862
2071
|
}
|
|
1863
2072
|
};
|
|
1864
2073
|
}
|
|
@@ -1894,6 +2103,10 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1894
2103
|
ok: true,
|
|
1895
2104
|
value: await handleRotationSet(payload)
|
|
1896
2105
|
};
|
|
2106
|
+
case "rotationRestore": return {
|
|
2107
|
+
ok: true,
|
|
2108
|
+
value: await handleRotationRestore(payload)
|
|
2109
|
+
};
|
|
1897
2110
|
case "removeFont": return {
|
|
1898
2111
|
ok: true,
|
|
1899
2112
|
value: await removeFontFile()
|
|
@@ -1918,7 +2131,43 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1918
2131
|
};
|
|
1919
2132
|
}
|
|
1920
2133
|
}
|
|
2134
|
+
const KEEP_CORRUPT_ARCHIVES = 3;
|
|
2135
|
+
const ORPHAN_GRACE_MS = 6e5;
|
|
2136
|
+
async function sweepDataDir() {
|
|
2137
|
+
try {
|
|
2138
|
+
const entries = await readdir(dataDir());
|
|
2139
|
+
for (const name of entries) if (name === UPLOAD_TMP || name === VIDEO_UPLOAD_TMP || name === FONT_UPLOAD_TMP || name === VIDEO_DOWNLOAD_TMP) await rm(dshHomePath(DATA_DIR, name), { force: true });
|
|
2140
|
+
const corrupts = [];
|
|
2141
|
+
for (const name of entries) {
|
|
2142
|
+
if (!/^theme-config\.json\.corrupt-\d+$/.test(name)) continue;
|
|
2143
|
+
try {
|
|
2144
|
+
const st = await stat(dshHomePath(DATA_DIR, name));
|
|
2145
|
+
corrupts.push({
|
|
2146
|
+
name,
|
|
2147
|
+
mtime: st.mtimeMs
|
|
2148
|
+
});
|
|
2149
|
+
} catch {}
|
|
2150
|
+
}
|
|
2151
|
+
corrupts.sort((a, b) => b.mtime - a.mtime);
|
|
2152
|
+
for (const c of corrupts.slice(KEEP_CORRUPT_ARCHIVES)) await rm(dshHomePath(DATA_DIR, c.name), { force: true });
|
|
2153
|
+
if (await exists(rotationDir())) {
|
|
2154
|
+
const referenced = new Set((await readConfig()).rotation.items.map((it) => it.file));
|
|
2155
|
+
const now = Date.now();
|
|
2156
|
+
for (const name of await readdir(rotationDir())) {
|
|
2157
|
+
if (referenced.has(name)) continue;
|
|
2158
|
+
const p = dshHomePath(DATA_DIR, ROTATION_DIR, name);
|
|
2159
|
+
try {
|
|
2160
|
+
if (now - (await stat(p)).mtimeMs < ORPHAN_GRACE_MS) continue;
|
|
2161
|
+
} catch {
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
await rm(p, { force: true });
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
} catch {}
|
|
2168
|
+
}
|
|
1921
2169
|
function apply(ctx) {
|
|
2170
|
+
sweepDataDir();
|
|
1922
2171
|
const hostInfo = resolveHostInfo(ctx.profileContext?.installAnchor).catch((e) => {
|
|
1923
2172
|
console.warn("dsh-any-background: host release detection failed, falling back to DOM probing", e);
|
|
1924
2173
|
return UNKNOWN_HOST_INFO;
|
|
@@ -2063,6 +2312,11 @@ function apply(ctx) {
|
|
|
2063
2312
|
path: WALLPAPER_UPLOAD_ROUTE,
|
|
2064
2313
|
handler: fenceUpload(handleWallpaperUpload)
|
|
2065
2314
|
}), "dsh-any-background: wallpaper upload route");
|
|
2315
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
2316
|
+
kind: "prefix",
|
|
2317
|
+
path: ROTATION_FILE_ROUTE,
|
|
2318
|
+
handler: serveRotationFile
|
|
2319
|
+
}), "dsh-any-background: rotation file route");
|
|
2066
2320
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2067
2321
|
kind: "prefix",
|
|
2068
2322
|
path: FONT_ROUTE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-background",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Appearance plugin for DeepSeek Harness: custom theme color (PS-style color wheel), background wallpaper with opacity/blur controls, and separate main/settings interface opacity.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|