dsh-any-background 0.3.10 → 0.4.1
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 +13 -12
- package/README.zh.md +13 -12
- package/lib/client.js +313 -92
- package/lib/client.js.map +1 -1
- package/lib/index.js +325 -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) {
|
|
@@ -472,6 +526,21 @@ const exists = async (p) => {
|
|
|
472
526
|
return false;
|
|
473
527
|
}
|
|
474
528
|
};
|
|
529
|
+
/** Read a file's first bytes for magic-number sniffing (null when unreadable). */
|
|
530
|
+
const readHead = async (p, n) => {
|
|
531
|
+
try {
|
|
532
|
+
const head = Buffer.alloc(n);
|
|
533
|
+
const fh = await open(p, "r");
|
|
534
|
+
try {
|
|
535
|
+
const { bytesRead } = await fh.read(head, 0, n, 0);
|
|
536
|
+
return bytesRead === 0 ? null : head.subarray(0, bytesRead);
|
|
537
|
+
} finally {
|
|
538
|
+
await fh.close();
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
475
544
|
/** Locate the stored video: the recorded MIME decides the expected name; a
|
|
476
545
|
* legacy extensionless wallpaper.video is renamed on first access. */
|
|
477
546
|
async function findVideoFile() {
|
|
@@ -493,6 +562,20 @@ async function findVideoFile() {
|
|
|
493
562
|
} catch {
|
|
494
563
|
return null;
|
|
495
564
|
}
|
|
565
|
+
if (cfg.videoMime === null) {
|
|
566
|
+
const mime = mimeForVideoFile(name);
|
|
567
|
+
if (mime !== null) {
|
|
568
|
+
const fresh = await readConfig();
|
|
569
|
+
if (fresh.videoMime !== mime) {
|
|
570
|
+
fresh.videoMime = mime;
|
|
571
|
+
await writeConfig(fresh);
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
path: p,
|
|
575
|
+
mime
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
496
579
|
return {
|
|
497
580
|
path: p,
|
|
498
581
|
mime: cfg.videoMime
|
|
@@ -503,40 +586,50 @@ async function findVideoFile() {
|
|
|
503
586
|
function clamp(n, lo, hi, def) {
|
|
504
587
|
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
505
588
|
}
|
|
506
|
-
/** Locate the stored font: the recorded MIME decides the expected name
|
|
507
|
-
*
|
|
508
|
-
*
|
|
589
|
+
/** Locate the stored font: the recorded MIME decides the expected name. A
|
|
590
|
+
* stray from a lost config write (or one whose recorded MIME no longer
|
|
591
|
+
* matches the bytes on disk) is adopted by sniffing the file's REAL container
|
|
592
|
+
* magic — never by trusting its name: the old name-based rename moved woff2
|
|
593
|
+
* bytes into font.ttf, and the next lookup then served them as font/ttf. The
|
|
594
|
+
* sniffed answer is renamed into place and persisted, so later lookups
|
|
595
|
+
* resolve by name (mirrors the video slot's recovery). */
|
|
509
596
|
async function findFontFile() {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
597
|
+
const cfg = await readConfig();
|
|
598
|
+
if (cfg.fontMime !== null) {
|
|
599
|
+
const expected = fontPathFor(cfg.fontMime);
|
|
600
|
+
if (await exists(expected)) return {
|
|
601
|
+
path: expected,
|
|
602
|
+
mime: cfg.fontMime
|
|
603
|
+
};
|
|
604
|
+
}
|
|
516
605
|
for (const name of FONT_CANDIDATES) {
|
|
517
606
|
const p = dshHomePath(DATA_DIR, name);
|
|
518
607
|
if (!await exists(p)) continue;
|
|
519
|
-
const
|
|
608
|
+
const head = await readHead(p, 4);
|
|
609
|
+
if (head === null) continue;
|
|
610
|
+
const sniffed = sniffFontMime(head);
|
|
611
|
+
if (sniffed === null) continue;
|
|
612
|
+
const target = fontPathFor(sniffed);
|
|
520
613
|
try {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
};
|
|
614
|
+
if (target !== p) {
|
|
615
|
+
await rm(target, { force: true });
|
|
616
|
+
await rename(p, target);
|
|
617
|
+
}
|
|
526
618
|
} catch {
|
|
527
619
|
return null;
|
|
528
620
|
}
|
|
621
|
+
const fresh = await readConfig();
|
|
622
|
+
if (fresh.fontMime !== sniffed) {
|
|
623
|
+
fresh.fontMime = sniffed;
|
|
624
|
+
await writeConfig(fresh);
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
path: target,
|
|
628
|
+
mime: sniffed
|
|
629
|
+
};
|
|
529
630
|
}
|
|
530
631
|
return null;
|
|
531
632
|
}
|
|
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
633
|
async function fontUrl() {
|
|
541
634
|
return await findFontFile() ? FONT_ROUTE : null;
|
|
542
635
|
}
|
|
@@ -785,7 +878,7 @@ function normalizeRotation(raw) {
|
|
|
785
878
|
return {
|
|
786
879
|
enabled: r.enabled === true,
|
|
787
880
|
mode: r.mode === "order" ? "order" : "shuffle",
|
|
788
|
-
interval: r.interval === "reload" || r.interval === "weekly" ? r.interval : "daily",
|
|
881
|
+
interval: r.interval === "reload" || r.interval === "hourly" || r.interval === "weekly" ? r.interval : "daily",
|
|
789
882
|
current: typeof r.current === "number" && isFinite(r.current) && r.current >= 0 ? Math.floor(r.current) : 0,
|
|
790
883
|
items,
|
|
791
884
|
lastRotate: typeof r.lastRotate === "string" ? r.lastRotate : null
|
|
@@ -802,9 +895,12 @@ function normalizeSchedule(raw) {
|
|
|
802
895
|
nightStart: typeof r.nightStart === "string" && HHMM_RE.test(r.nightStart) ? r.nightStart : DEFAULT_CONFIG.schedule.nightStart
|
|
803
896
|
};
|
|
804
897
|
}
|
|
898
|
+
let dirEnsured = false;
|
|
805
899
|
async function ensureDir() {
|
|
900
|
+
if (dirEnsured) return;
|
|
806
901
|
try {
|
|
807
902
|
await mkdir(dataDir(), { recursive: true });
|
|
903
|
+
dirEnsured = true;
|
|
808
904
|
} catch (e) {
|
|
809
905
|
console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
|
|
810
906
|
}
|
|
@@ -830,7 +926,7 @@ async function readConfig() {
|
|
|
830
926
|
text = await readFile(file, "utf8");
|
|
831
927
|
} catch {
|
|
832
928
|
dropConfigCache();
|
|
833
|
-
return
|
|
929
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
834
930
|
}
|
|
835
931
|
try {
|
|
836
932
|
const parsed = normalizeConfig(JSON.parse(text));
|
|
@@ -844,7 +940,7 @@ async function readConfig() {
|
|
|
844
940
|
console.warn(`dsh-any-background: theme-config.json was corrupt, archived to "${into}" and reset to defaults`, e);
|
|
845
941
|
} catch {}
|
|
846
942
|
dropConfigCache();
|
|
847
|
-
return
|
|
943
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
848
944
|
}
|
|
849
945
|
}
|
|
850
946
|
const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set(["opacity"]);
|
|
@@ -875,13 +971,13 @@ function warnUnknownConfigKeys(raw, normalized) {
|
|
|
875
971
|
}
|
|
876
972
|
async function writeConfig(config) {
|
|
877
973
|
await ensureDir();
|
|
974
|
+
dropConfigCache();
|
|
878
975
|
const tmp = `${configPath()}.tmp`;
|
|
879
976
|
try {
|
|
880
977
|
const normalized = normalizeConfig(config);
|
|
881
978
|
warnUnknownConfigKeys(config, normalized);
|
|
882
979
|
await writeFile(tmp, JSON.stringify(normalized, null, 2), "utf8");
|
|
883
980
|
await rename(tmp, configPath());
|
|
884
|
-
dropConfigCache();
|
|
885
981
|
return true;
|
|
886
982
|
} catch (e) {
|
|
887
983
|
console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
|
|
@@ -1065,7 +1161,7 @@ async function handleRotationAdd(payload) {
|
|
|
1065
1161
|
ok: false,
|
|
1066
1162
|
error: "too many items"
|
|
1067
1163
|
};
|
|
1068
|
-
const file = `wp-${Date.now().toString(36)}.${imageExtFor(sniffImageMime(buf))}`;
|
|
1164
|
+
const file = `wp-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}.${imageExtFor(sniffImageMime(buf))}`;
|
|
1069
1165
|
try {
|
|
1070
1166
|
await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, file), buf);
|
|
1071
1167
|
} catch (e) {
|
|
@@ -1132,27 +1228,99 @@ async function handleRotationSet(payload) {
|
|
|
1132
1228
|
ok: false,
|
|
1133
1229
|
error: "not found"
|
|
1134
1230
|
};
|
|
1135
|
-
let buf;
|
|
1136
1231
|
try {
|
|
1137
|
-
|
|
1138
|
-
} catch {
|
|
1232
|
+
await copyFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file), wallpaperPath());
|
|
1233
|
+
} catch (e) {
|
|
1234
|
+
console.error("dsh-any-background: failed to activate a rotation wallpaper", e);
|
|
1139
1235
|
return {
|
|
1140
1236
|
ok: false,
|
|
1141
|
-
error: "file missing"
|
|
1237
|
+
error: typeof e === "object" && e !== null && e.code === "ENOENT" ? "file missing" : "write failed"
|
|
1142
1238
|
};
|
|
1143
1239
|
}
|
|
1144
1240
|
try {
|
|
1145
|
-
await
|
|
1146
|
-
|
|
1147
|
-
|
|
1241
|
+
const fresh = await readConfig();
|
|
1242
|
+
if (fresh.rotation.items[i] !== void 0) {
|
|
1243
|
+
if (!await writeConfig({
|
|
1244
|
+
...fresh,
|
|
1245
|
+
rotation: {
|
|
1246
|
+
...fresh.rotation,
|
|
1247
|
+
current: i,
|
|
1248
|
+
lastRotate: fresh.rotation.lastRotate ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1249
|
+
}
|
|
1250
|
+
})) console.warn("dsh-any-background: rotation cursor did not persist (the client save will catch up)");
|
|
1251
|
+
}
|
|
1252
|
+
} catch {}
|
|
1253
|
+
return {
|
|
1254
|
+
ok: true,
|
|
1255
|
+
wallpaperUrl: WALLPAPER_ROUTE
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
/** Replace the whole rotation pool from an imported theme (v3 export): each
|
|
1259
|
+
* entry's bytes are written under the name the exported config's metadata
|
|
1260
|
+
* already points at, files the imported pool does not reference are dropped,
|
|
1261
|
+
* and the config's item list is rebuilt. Wholesale replacement matches import
|
|
1262
|
+
* semantics — the rotation state lands exactly as it left the source machine
|
|
1263
|
+
* (a v2 export could not carry pool bytes, so its entries pointed at files
|
|
1264
|
+
* that never travelled and every activation failed with 'file missing'). */
|
|
1265
|
+
async function handleRotationRestore(payload) {
|
|
1266
|
+
const raw = payload?.items;
|
|
1267
|
+
if (!Array.isArray(raw)) return {
|
|
1268
|
+
ok: false,
|
|
1269
|
+
error: "invalid payload"
|
|
1270
|
+
};
|
|
1271
|
+
const entries = [];
|
|
1272
|
+
for (const it of raw.slice(0, MAX_ROTATION_ITEMS)) {
|
|
1273
|
+
const e = it ?? {};
|
|
1274
|
+
const file = safeRotationFile(e.file);
|
|
1275
|
+
if (file === null) return {
|
|
1276
|
+
ok: false,
|
|
1277
|
+
error: "invalid file name"
|
|
1278
|
+
};
|
|
1279
|
+
if (entries.some((x) => x.file === file)) continue;
|
|
1280
|
+
const buf = decodeImageDataUrl(e.dataUrl);
|
|
1281
|
+
if (buf === null) return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
error: "invalid image"
|
|
1284
|
+
};
|
|
1285
|
+
entries.push({
|
|
1286
|
+
file,
|
|
1287
|
+
thumb: sanitizeThumb(e.thumb),
|
|
1288
|
+
buf
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
await ensureDir();
|
|
1292
|
+
await ensureRotationDir();
|
|
1293
|
+
if (await exists(rotationDir())) {
|
|
1294
|
+
for (const name of await readdir(rotationDir())) if (!entries.some((x) => x.file === name)) await rm(dshHomePath(DATA_DIR, ROTATION_DIR, name), { force: true });
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
for (const e of entries) await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, e.file), e.buf);
|
|
1298
|
+
} catch (err) {
|
|
1299
|
+
console.error("dsh-any-background: failed to restore the rotation pool", err);
|
|
1148
1300
|
return {
|
|
1149
1301
|
ok: false,
|
|
1150
1302
|
error: "write failed"
|
|
1151
1303
|
};
|
|
1152
1304
|
}
|
|
1305
|
+
const items = entries.map((e) => ({
|
|
1306
|
+
file: e.file,
|
|
1307
|
+
thumb: e.thumb
|
|
1308
|
+
}));
|
|
1309
|
+
const fresh = await readConfig();
|
|
1310
|
+
if (!await writeConfig({
|
|
1311
|
+
...fresh,
|
|
1312
|
+
rotation: {
|
|
1313
|
+
...fresh.rotation,
|
|
1314
|
+
current: Math.min(fresh.rotation.current, Math.max(0, items.length - 1)),
|
|
1315
|
+
items
|
|
1316
|
+
}
|
|
1317
|
+
})) return {
|
|
1318
|
+
ok: false,
|
|
1319
|
+
error: "config write failed"
|
|
1320
|
+
};
|
|
1153
1321
|
return {
|
|
1154
1322
|
ok: true,
|
|
1155
|
-
|
|
1323
|
+
items
|
|
1156
1324
|
};
|
|
1157
1325
|
}
|
|
1158
1326
|
async function videoUrl() {
|
|
@@ -1224,7 +1392,7 @@ async function writeVideoFromUrl(url) {
|
|
|
1224
1392
|
error: "too large"
|
|
1225
1393
|
};
|
|
1226
1394
|
await ensureDir();
|
|
1227
|
-
const tmp = dshHomePath(DATA_DIR,
|
|
1395
|
+
const tmp = dshHomePath(DATA_DIR, VIDEO_DOWNLOAD_TMP);
|
|
1228
1396
|
const target = videoPathFor(mime);
|
|
1229
1397
|
try {
|
|
1230
1398
|
if (res.body === null) return {
|
|
@@ -1530,16 +1698,13 @@ async function serveWallpaper(req, res) {
|
|
|
1530
1698
|
res.end();
|
|
1531
1699
|
return;
|
|
1532
1700
|
}
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
s.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1541
|
-
s.on("error", reject);
|
|
1542
|
-
}));
|
|
1701
|
+
const head = await readHead(wallpaperPath(), 16);
|
|
1702
|
+
if (head === null) {
|
|
1703
|
+
res.writeHead(404);
|
|
1704
|
+
res.end("no wallpaper stored");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
const mime = sniffImageMime(head);
|
|
1543
1708
|
res.writeHead(200, {
|
|
1544
1709
|
"Content-Type": mime,
|
|
1545
1710
|
"Content-Length": st.size,
|
|
@@ -1611,6 +1776,16 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1611
1776
|
}));
|
|
1612
1777
|
return;
|
|
1613
1778
|
}
|
|
1779
|
+
const head = await readHead(tmp, 12);
|
|
1780
|
+
if (head === null || !isImageBytes(head)) {
|
|
1781
|
+
await rm(tmp, { force: true });
|
|
1782
|
+
res.writeHead(415, { "Content-Type": "application/json" });
|
|
1783
|
+
res.end(JSON.stringify({
|
|
1784
|
+
ok: false,
|
|
1785
|
+
error: "not an image"
|
|
1786
|
+
}));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1614
1789
|
await rm(wallpaperPath(), { force: true });
|
|
1615
1790
|
await rename(tmp, wallpaperPath());
|
|
1616
1791
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1635,6 +1810,47 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1635
1810
|
} catch {}
|
|
1636
1811
|
}
|
|
1637
1812
|
}
|
|
1813
|
+
/** Serve one rotation-pool image by its `?f=<name>` query. The name goes
|
|
1814
|
+
* through the same pattern fence the config sanitizer uses (bare file name
|
|
1815
|
+
* with a known image extension), so there is no path traversal; unfenced like
|
|
1816
|
+
* the other image serve routes (an <img> fetch carries no auth headers). */
|
|
1817
|
+
async function serveRotationFile(req, res) {
|
|
1818
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1819
|
+
res.writeHead(405);
|
|
1820
|
+
res.end();
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
let name = null;
|
|
1824
|
+
try {
|
|
1825
|
+
name = safeRotationFile(new URL(req.url ?? "/", "http://localhost").searchParams.get("f"));
|
|
1826
|
+
} catch {
|
|
1827
|
+
name = null;
|
|
1828
|
+
}
|
|
1829
|
+
if (name === null) {
|
|
1830
|
+
res.writeHead(400);
|
|
1831
|
+
res.end("invalid file name");
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const p = dshHomePath(DATA_DIR, ROTATION_DIR, name);
|
|
1836
|
+
const st = await stat(p);
|
|
1837
|
+
const head = await readHead(p, 12);
|
|
1838
|
+
if (head === null) throw new Error("unreadable");
|
|
1839
|
+
res.writeHead(200, {
|
|
1840
|
+
"Content-Type": sniffImageMime(head),
|
|
1841
|
+
"Content-Length": st.size,
|
|
1842
|
+
"Cache-Control": "no-store"
|
|
1843
|
+
});
|
|
1844
|
+
if (req.method === "HEAD") {
|
|
1845
|
+
res.end();
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
createReadStream(p).pipe(res);
|
|
1849
|
+
} catch {
|
|
1850
|
+
res.writeHead(404);
|
|
1851
|
+
res.end("no such rotation image");
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1638
1854
|
/** Stream the stored custom font: sniffed MIME, no caching (uploads replace
|
|
1639
1855
|
* the file in place). Fonts need no Range support — the browser fetches once. */
|
|
1640
1856
|
async function serveFont(req, res) {
|
|
@@ -1794,36 +2010,22 @@ async function removeFontFile() {
|
|
|
1794
2010
|
const NS = "dshAnyBackground";
|
|
1795
2011
|
const RPC_CHANNEL = "/dsh-any-background";
|
|
1796
2012
|
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
2013
|
/** Advance the rotation pool when its cadence is due: pick the next item,
|
|
1808
2014
|
* copy it over the active wallpaper slot, and persist the rotation state.
|
|
1809
2015
|
* Runs inside `read` so a reload restores the NEW wallpaper directly — the
|
|
1810
2016
|
* old one never reaches the screen. The client's maybeRotate stays as a
|
|
1811
|
-
* fallback and skips when this already advanced (the `rotated` flag).
|
|
2017
|
+
* fallback and skips when this already advanced (the `rotated` flag).
|
|
2018
|
+
* The due/next math is the SHARED implementation (`src/shared/rotation.ts`)
|
|
2019
|
+
* so both halves can never disagree on a day/week boundary. */
|
|
1812
2020
|
async function advanceRotationIfDue() {
|
|
1813
2021
|
const rot = (await readConfig()).rotation;
|
|
1814
|
-
if (!rot
|
|
2022
|
+
if (!rotationDue(rot)) return false;
|
|
1815
2023
|
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;
|
|
2024
|
+
const idx = pickNextRotationIndex(rot);
|
|
1822
2025
|
const item = rot.items[idx];
|
|
1823
2026
|
if (item === void 0) return false;
|
|
1824
2027
|
try {
|
|
1825
|
-
|
|
1826
|
-
await writeFile(wallpaperPath(), buf);
|
|
2028
|
+
await copyFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file), wallpaperPath());
|
|
1827
2029
|
} catch (e) {
|
|
1828
2030
|
console.error("dsh-any-background: failed to advance the rotation pool", e);
|
|
1829
2031
|
return false;
|
|
@@ -1848,17 +2050,22 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1848
2050
|
const rotated = await advanceRotationIfDue();
|
|
1849
2051
|
const config = await readConfig();
|
|
1850
2052
|
const firstRun = !await exists(configPath());
|
|
1851
|
-
|
|
2053
|
+
const [wpServeUrl, vidServeUrl, fntServeUrl, host] = await Promise.all([
|
|
2054
|
+
wallpaperServeUrl(),
|
|
2055
|
+
videoUrl(),
|
|
2056
|
+
fontUrl(),
|
|
2057
|
+
hostInfo
|
|
2058
|
+
]);
|
|
1852
2059
|
return {
|
|
1853
2060
|
ok: true,
|
|
1854
2061
|
value: {
|
|
1855
2062
|
config,
|
|
1856
|
-
wallpaperUrl:
|
|
1857
|
-
videoUrl:
|
|
1858
|
-
fontUrl:
|
|
2063
|
+
wallpaperUrl: wpServeUrl,
|
|
2064
|
+
videoUrl: vidServeUrl,
|
|
2065
|
+
fontUrl: fntServeUrl,
|
|
1859
2066
|
rotated,
|
|
1860
2067
|
firstRun,
|
|
1861
|
-
host
|
|
2068
|
+
host
|
|
1862
2069
|
}
|
|
1863
2070
|
};
|
|
1864
2071
|
}
|
|
@@ -1894,6 +2101,10 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1894
2101
|
ok: true,
|
|
1895
2102
|
value: await handleRotationSet(payload)
|
|
1896
2103
|
};
|
|
2104
|
+
case "rotationRestore": return {
|
|
2105
|
+
ok: true,
|
|
2106
|
+
value: await handleRotationRestore(payload)
|
|
2107
|
+
};
|
|
1897
2108
|
case "removeFont": return {
|
|
1898
2109
|
ok: true,
|
|
1899
2110
|
value: await removeFontFile()
|
|
@@ -1918,7 +2129,43 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1918
2129
|
};
|
|
1919
2130
|
}
|
|
1920
2131
|
}
|
|
2132
|
+
const KEEP_CORRUPT_ARCHIVES = 3;
|
|
2133
|
+
const ORPHAN_GRACE_MS = 6e5;
|
|
2134
|
+
async function sweepDataDir() {
|
|
2135
|
+
try {
|
|
2136
|
+
const entries = await readdir(dataDir());
|
|
2137
|
+
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 });
|
|
2138
|
+
const corrupts = [];
|
|
2139
|
+
for (const name of entries) {
|
|
2140
|
+
if (!/^theme-config\.json\.corrupt-\d+$/.test(name)) continue;
|
|
2141
|
+
try {
|
|
2142
|
+
const st = await stat(dshHomePath(DATA_DIR, name));
|
|
2143
|
+
corrupts.push({
|
|
2144
|
+
name,
|
|
2145
|
+
mtime: st.mtimeMs
|
|
2146
|
+
});
|
|
2147
|
+
} catch {}
|
|
2148
|
+
}
|
|
2149
|
+
corrupts.sort((a, b) => b.mtime - a.mtime);
|
|
2150
|
+
for (const c of corrupts.slice(KEEP_CORRUPT_ARCHIVES)) await rm(dshHomePath(DATA_DIR, c.name), { force: true });
|
|
2151
|
+
if (await exists(rotationDir())) {
|
|
2152
|
+
const referenced = new Set((await readConfig()).rotation.items.map((it) => it.file));
|
|
2153
|
+
const now = Date.now();
|
|
2154
|
+
for (const name of await readdir(rotationDir())) {
|
|
2155
|
+
if (referenced.has(name)) continue;
|
|
2156
|
+
const p = dshHomePath(DATA_DIR, ROTATION_DIR, name);
|
|
2157
|
+
try {
|
|
2158
|
+
if (now - (await stat(p)).mtimeMs < ORPHAN_GRACE_MS) continue;
|
|
2159
|
+
} catch {
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
await rm(p, { force: true });
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
} catch {}
|
|
2166
|
+
}
|
|
1921
2167
|
function apply(ctx) {
|
|
2168
|
+
sweepDataDir();
|
|
1922
2169
|
const hostInfo = resolveHostInfo(ctx.profileContext?.installAnchor).catch((e) => {
|
|
1923
2170
|
console.warn("dsh-any-background: host release detection failed, falling back to DOM probing", e);
|
|
1924
2171
|
return UNKNOWN_HOST_INFO;
|
|
@@ -2063,6 +2310,11 @@ function apply(ctx) {
|
|
|
2063
2310
|
path: WALLPAPER_UPLOAD_ROUTE,
|
|
2064
2311
|
handler: fenceUpload(handleWallpaperUpload)
|
|
2065
2312
|
}), "dsh-any-background: wallpaper upload route");
|
|
2313
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
2314
|
+
kind: "prefix",
|
|
2315
|
+
path: ROTATION_FILE_ROUTE,
|
|
2316
|
+
handler: serveRotationFile
|
|
2317
|
+
}), "dsh-any-background: rotation file route");
|
|
2066
2318
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2067
2319
|
kind: "prefix",
|
|
2068
2320
|
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.1",
|
|
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",
|