dsh-any-background 0.3.9 → 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 +17 -24
- package/README.zh.md +17 -24
- package/lib/client.js +349 -95
- package/lib/client.js.map +1 -1
- package/lib/index.js +329 -73
- package/package.json +11 -10
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";
|
|
@@ -50,6 +51,10 @@ const SUPPORTED_RELEASES = [
|
|
|
50
51
|
{
|
|
51
52
|
channel: "0.2.1",
|
|
52
53
|
release: "0.2.1-rc.2"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
channel: "0.2.1",
|
|
57
|
+
release: "0.2.1-rc.3"
|
|
53
58
|
}
|
|
54
59
|
];
|
|
55
60
|
const UNKNOWN_HOST_INFO = {
|
|
@@ -245,6 +250,42 @@ async function resolveHostInfo(installAnchor) {
|
|
|
245
250
|
return cache;
|
|
246
251
|
}
|
|
247
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
|
|
248
289
|
//#region src/index.ts
|
|
249
290
|
/**
|
|
250
291
|
* Node half of dsh-any-background: file-backed theme persistence.
|
|
@@ -272,9 +313,12 @@ const WALLPAPER_ROUTE = "/dsh-any-background/wallpaper";
|
|
|
272
313
|
const WALLPAPER_UPLOAD_ROUTE = "/dsh-any-background/wallpaper/upload";
|
|
273
314
|
const FONT_ROUTE = "/dsh-any-background/font";
|
|
274
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";
|
|
275
318
|
const UPLOAD_TMP = "wallpaper.upload.tmp";
|
|
276
319
|
const VIDEO_UPLOAD_TMP = "video.upload.tmp";
|
|
277
320
|
const FONT_UPLOAD_TMP = "font.upload.tmp";
|
|
321
|
+
const VIDEO_DOWNLOAD_TMP = "video.download.tmp";
|
|
278
322
|
const WALLPAPER_UPLOAD_MAX = 104857600;
|
|
279
323
|
const VIDEO_UPLOAD_MAX = 2147483648;
|
|
280
324
|
const FONT_UPLOAD_MAX = 104857600;
|
|
@@ -301,6 +345,20 @@ const VIDEO_CANDIDATES = [
|
|
|
301
345
|
"wallpaper.mkv",
|
|
302
346
|
"wallpaper.video"
|
|
303
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
|
+
}
|
|
304
362
|
/** Font slot: one font owns the slot, named by format like the video slot. */
|
|
305
363
|
function fontFileName(mime) {
|
|
306
364
|
switch (mime) {
|
|
@@ -468,6 +526,21 @@ const exists = async (p) => {
|
|
|
468
526
|
return false;
|
|
469
527
|
}
|
|
470
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
|
+
};
|
|
471
544
|
/** Locate the stored video: the recorded MIME decides the expected name; a
|
|
472
545
|
* legacy extensionless wallpaper.video is renamed on first access. */
|
|
473
546
|
async function findVideoFile() {
|
|
@@ -489,6 +562,20 @@ async function findVideoFile() {
|
|
|
489
562
|
} catch {
|
|
490
563
|
return null;
|
|
491
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
|
+
}
|
|
492
579
|
return {
|
|
493
580
|
path: p,
|
|
494
581
|
mime: cfg.videoMime
|
|
@@ -499,40 +586,50 @@ async function findVideoFile() {
|
|
|
499
586
|
function clamp(n, lo, hi, def) {
|
|
500
587
|
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
501
588
|
}
|
|
502
|
-
/** Locate the stored font: the recorded MIME decides the expected name
|
|
503
|
-
*
|
|
504
|
-
*
|
|
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). */
|
|
505
596
|
async function findFontFile() {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
}
|
|
512
605
|
for (const name of FONT_CANDIDATES) {
|
|
513
606
|
const p = dshHomePath(DATA_DIR, name);
|
|
514
607
|
if (!await exists(p)) continue;
|
|
515
|
-
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);
|
|
516
613
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
};
|
|
614
|
+
if (target !== p) {
|
|
615
|
+
await rm(target, { force: true });
|
|
616
|
+
await rename(p, target);
|
|
617
|
+
}
|
|
522
618
|
} catch {
|
|
523
619
|
return null;
|
|
524
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
|
+
};
|
|
525
630
|
}
|
|
526
631
|
return null;
|
|
527
632
|
}
|
|
528
|
-
function mimeForFontFile(name) {
|
|
529
|
-
switch (name) {
|
|
530
|
-
case "font.woff2": return "font/woff2";
|
|
531
|
-
case "font.woff": return "font/woff";
|
|
532
|
-
case "font.otf": return "font/otf";
|
|
533
|
-
default: return "font/ttf";
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
633
|
async function fontUrl() {
|
|
537
634
|
return await findFontFile() ? FONT_ROUTE : null;
|
|
538
635
|
}
|
|
@@ -781,7 +878,7 @@ function normalizeRotation(raw) {
|
|
|
781
878
|
return {
|
|
782
879
|
enabled: r.enabled === true,
|
|
783
880
|
mode: r.mode === "order" ? "order" : "shuffle",
|
|
784
|
-
interval: r.interval === "reload" || r.interval === "weekly" ? r.interval : "daily",
|
|
881
|
+
interval: r.interval === "reload" || r.interval === "hourly" || r.interval === "weekly" ? r.interval : "daily",
|
|
785
882
|
current: typeof r.current === "number" && isFinite(r.current) && r.current >= 0 ? Math.floor(r.current) : 0,
|
|
786
883
|
items,
|
|
787
884
|
lastRotate: typeof r.lastRotate === "string" ? r.lastRotate : null
|
|
@@ -798,9 +895,12 @@ function normalizeSchedule(raw) {
|
|
|
798
895
|
nightStart: typeof r.nightStart === "string" && HHMM_RE.test(r.nightStart) ? r.nightStart : DEFAULT_CONFIG.schedule.nightStart
|
|
799
896
|
};
|
|
800
897
|
}
|
|
898
|
+
let dirEnsured = false;
|
|
801
899
|
async function ensureDir() {
|
|
900
|
+
if (dirEnsured) return;
|
|
802
901
|
try {
|
|
803
902
|
await mkdir(dataDir(), { recursive: true });
|
|
903
|
+
dirEnsured = true;
|
|
804
904
|
} catch (e) {
|
|
805
905
|
console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
|
|
806
906
|
}
|
|
@@ -826,7 +926,7 @@ async function readConfig() {
|
|
|
826
926
|
text = await readFile(file, "utf8");
|
|
827
927
|
} catch {
|
|
828
928
|
dropConfigCache();
|
|
829
|
-
return
|
|
929
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
830
930
|
}
|
|
831
931
|
try {
|
|
832
932
|
const parsed = normalizeConfig(JSON.parse(text));
|
|
@@ -840,7 +940,7 @@ async function readConfig() {
|
|
|
840
940
|
console.warn(`dsh-any-background: theme-config.json was corrupt, archived to "${into}" and reset to defaults`, e);
|
|
841
941
|
} catch {}
|
|
842
942
|
dropConfigCache();
|
|
843
|
-
return
|
|
943
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
844
944
|
}
|
|
845
945
|
}
|
|
846
946
|
const LEGACY_CONFIG_KEYS = /* @__PURE__ */ new Set(["opacity"]);
|
|
@@ -871,13 +971,13 @@ function warnUnknownConfigKeys(raw, normalized) {
|
|
|
871
971
|
}
|
|
872
972
|
async function writeConfig(config) {
|
|
873
973
|
await ensureDir();
|
|
974
|
+
dropConfigCache();
|
|
874
975
|
const tmp = `${configPath()}.tmp`;
|
|
875
976
|
try {
|
|
876
977
|
const normalized = normalizeConfig(config);
|
|
877
978
|
warnUnknownConfigKeys(config, normalized);
|
|
878
979
|
await writeFile(tmp, JSON.stringify(normalized, null, 2), "utf8");
|
|
879
980
|
await rename(tmp, configPath());
|
|
880
|
-
dropConfigCache();
|
|
881
981
|
return true;
|
|
882
982
|
} catch (e) {
|
|
883
983
|
console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
|
|
@@ -1061,7 +1161,7 @@ async function handleRotationAdd(payload) {
|
|
|
1061
1161
|
ok: false,
|
|
1062
1162
|
error: "too many items"
|
|
1063
1163
|
};
|
|
1064
|
-
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))}`;
|
|
1065
1165
|
try {
|
|
1066
1166
|
await writeFile(dshHomePath(DATA_DIR, ROTATION_DIR, file), buf);
|
|
1067
1167
|
} catch (e) {
|
|
@@ -1128,27 +1228,99 @@ async function handleRotationSet(payload) {
|
|
|
1128
1228
|
ok: false,
|
|
1129
1229
|
error: "not found"
|
|
1130
1230
|
};
|
|
1131
|
-
let buf;
|
|
1132
1231
|
try {
|
|
1133
|
-
|
|
1134
|
-
} 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);
|
|
1135
1235
|
return {
|
|
1136
1236
|
ok: false,
|
|
1137
|
-
error: "file missing"
|
|
1237
|
+
error: typeof e === "object" && e !== null && e.code === "ENOENT" ? "file missing" : "write failed"
|
|
1138
1238
|
};
|
|
1139
1239
|
}
|
|
1140
1240
|
try {
|
|
1141
|
-
await
|
|
1142
|
-
|
|
1143
|
-
|
|
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);
|
|
1144
1300
|
return {
|
|
1145
1301
|
ok: false,
|
|
1146
1302
|
error: "write failed"
|
|
1147
1303
|
};
|
|
1148
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
|
+
};
|
|
1149
1321
|
return {
|
|
1150
1322
|
ok: true,
|
|
1151
|
-
|
|
1323
|
+
items
|
|
1152
1324
|
};
|
|
1153
1325
|
}
|
|
1154
1326
|
async function videoUrl() {
|
|
@@ -1220,7 +1392,7 @@ async function writeVideoFromUrl(url) {
|
|
|
1220
1392
|
error: "too large"
|
|
1221
1393
|
};
|
|
1222
1394
|
await ensureDir();
|
|
1223
|
-
const tmp = dshHomePath(DATA_DIR,
|
|
1395
|
+
const tmp = dshHomePath(DATA_DIR, VIDEO_DOWNLOAD_TMP);
|
|
1224
1396
|
const target = videoPathFor(mime);
|
|
1225
1397
|
try {
|
|
1226
1398
|
if (res.body === null) return {
|
|
@@ -1526,16 +1698,13 @@ async function serveWallpaper(req, res) {
|
|
|
1526
1698
|
res.end();
|
|
1527
1699
|
return;
|
|
1528
1700
|
}
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
s.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1537
|
-
s.on("error", reject);
|
|
1538
|
-
}));
|
|
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);
|
|
1539
1708
|
res.writeHead(200, {
|
|
1540
1709
|
"Content-Type": mime,
|
|
1541
1710
|
"Content-Length": st.size,
|
|
@@ -1607,6 +1776,16 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1607
1776
|
}));
|
|
1608
1777
|
return;
|
|
1609
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
|
+
}
|
|
1610
1789
|
await rm(wallpaperPath(), { force: true });
|
|
1611
1790
|
await rename(tmp, wallpaperPath());
|
|
1612
1791
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1631,6 +1810,47 @@ async function handleWallpaperUpload(req, res) {
|
|
|
1631
1810
|
} catch {}
|
|
1632
1811
|
}
|
|
1633
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
|
+
}
|
|
1634
1854
|
/** Stream the stored custom font: sniffed MIME, no caching (uploads replace
|
|
1635
1855
|
* the file in place). Fonts need no Range support — the browser fetches once. */
|
|
1636
1856
|
async function serveFont(req, res) {
|
|
@@ -1790,36 +2010,22 @@ async function removeFontFile() {
|
|
|
1790
2010
|
const NS = "dshAnyBackground";
|
|
1791
2011
|
const RPC_CHANNEL = "/dsh-any-background";
|
|
1792
2012
|
const RPC_BODY_MAX = 314572800;
|
|
1793
|
-
/** ISO week key — mirrors the client's rotationDue so daily/weekly cadence
|
|
1794
|
-
* decisions agree across both halves. */
|
|
1795
|
-
function isoWeekKey(d) {
|
|
1796
|
-
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
1797
|
-
const day = t.getUTCDay() || 7;
|
|
1798
|
-
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
1799
|
-
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
1800
|
-
const week = Math.ceil(((t.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
1801
|
-
return `${t.getUTCFullYear()}-W${week}`;
|
|
1802
|
-
}
|
|
1803
2013
|
/** Advance the rotation pool when its cadence is due: pick the next item,
|
|
1804
2014
|
* copy it over the active wallpaper slot, and persist the rotation state.
|
|
1805
2015
|
* Runs inside `read` so a reload restores the NEW wallpaper directly — the
|
|
1806
2016
|
* old one never reaches the screen. The client's maybeRotate stays as a
|
|
1807
|
-
* 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. */
|
|
1808
2020
|
async function advanceRotationIfDue() {
|
|
1809
2021
|
const rot = (await readConfig()).rotation;
|
|
1810
|
-
if (!rot
|
|
2022
|
+
if (!rotationDue(rot)) return false;
|
|
1811
2023
|
const now = /* @__PURE__ */ new Date();
|
|
1812
|
-
const
|
|
1813
|
-
if (!(rot.interval === "reload" || last === null || isNaN(last.getTime()) || (rot.interval === "daily" ? last.toDateString() !== now.toDateString() : isoWeekKey(last) !== isoWeekKey(now)))) return false;
|
|
1814
|
-
const n = rot.items.length;
|
|
1815
|
-
let idx = rot.current;
|
|
1816
|
-
if (rot.mode === "shuffle" && n > 1) while (idx === rot.current) idx = Math.floor(Math.random() * n);
|
|
1817
|
-
else idx = (rot.current + 1) % n;
|
|
2024
|
+
const idx = pickNextRotationIndex(rot);
|
|
1818
2025
|
const item = rot.items[idx];
|
|
1819
2026
|
if (item === void 0) return false;
|
|
1820
2027
|
try {
|
|
1821
|
-
|
|
1822
|
-
await writeFile(wallpaperPath(), buf);
|
|
2028
|
+
await copyFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file), wallpaperPath());
|
|
1823
2029
|
} catch (e) {
|
|
1824
2030
|
console.error("dsh-any-background: failed to advance the rotation pool", e);
|
|
1825
2031
|
return false;
|
|
@@ -1844,17 +2050,22 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1844
2050
|
const rotated = await advanceRotationIfDue();
|
|
1845
2051
|
const config = await readConfig();
|
|
1846
2052
|
const firstRun = !await exists(configPath());
|
|
1847
|
-
|
|
2053
|
+
const [wpServeUrl, vidServeUrl, fntServeUrl, host] = await Promise.all([
|
|
2054
|
+
wallpaperServeUrl(),
|
|
2055
|
+
videoUrl(),
|
|
2056
|
+
fontUrl(),
|
|
2057
|
+
hostInfo
|
|
2058
|
+
]);
|
|
1848
2059
|
return {
|
|
1849
2060
|
ok: true,
|
|
1850
2061
|
value: {
|
|
1851
2062
|
config,
|
|
1852
|
-
wallpaperUrl:
|
|
1853
|
-
videoUrl:
|
|
1854
|
-
fontUrl:
|
|
2063
|
+
wallpaperUrl: wpServeUrl,
|
|
2064
|
+
videoUrl: vidServeUrl,
|
|
2065
|
+
fontUrl: fntServeUrl,
|
|
1855
2066
|
rotated,
|
|
1856
2067
|
firstRun,
|
|
1857
|
-
host
|
|
2068
|
+
host
|
|
1858
2069
|
}
|
|
1859
2070
|
};
|
|
1860
2071
|
}
|
|
@@ -1890,6 +2101,10 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1890
2101
|
ok: true,
|
|
1891
2102
|
value: await handleRotationSet(payload)
|
|
1892
2103
|
};
|
|
2104
|
+
case "rotationRestore": return {
|
|
2105
|
+
ok: true,
|
|
2106
|
+
value: await handleRotationRestore(payload)
|
|
2107
|
+
};
|
|
1893
2108
|
case "removeFont": return {
|
|
1894
2109
|
ok: true,
|
|
1895
2110
|
value: await removeFontFile()
|
|
@@ -1914,7 +2129,43 @@ async function handleRpcMethod(endpoint, payload, hostInfo) {
|
|
|
1914
2129
|
};
|
|
1915
2130
|
}
|
|
1916
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
|
+
}
|
|
1917
2167
|
function apply(ctx) {
|
|
2168
|
+
sweepDataDir();
|
|
1918
2169
|
const hostInfo = resolveHostInfo(ctx.profileContext?.installAnchor).catch((e) => {
|
|
1919
2170
|
console.warn("dsh-any-background: host release detection failed, falling back to DOM probing", e);
|
|
1920
2171
|
return UNKNOWN_HOST_INFO;
|
|
@@ -2059,6 +2310,11 @@ function apply(ctx) {
|
|
|
2059
2310
|
path: WALLPAPER_UPLOAD_ROUTE,
|
|
2060
2311
|
handler: fenceUpload(handleWallpaperUpload)
|
|
2061
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");
|
|
2062
2318
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2063
2319
|
kind: "prefix",
|
|
2064
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",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"author": "Tkingxiao",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"engines": {
|
|
26
|
-
"dsh": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1"
|
|
26
|
+
"dsh": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1"
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
@@ -64,12 +64,12 @@
|
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
67
|
-
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
68
|
-
"@deepseek-ai/dsh-client-store": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
69
|
-
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-theme": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-client-store": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-theme": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
73
73
|
"react": "^18.2.0",
|
|
74
74
|
"dsh-better-sidebar": ">=0.4.0"
|
|
75
75
|
},
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
},
|
|
105
105
|
"dsh": {
|
|
106
106
|
"compatibility": {
|
|
107
|
-
"dsh": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.2.0-rc.1",
|
|
107
|
+
"dsh": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || >=0.2.0-rc.1",
|
|
108
108
|
"dshReleases": {
|
|
109
109
|
"0.2.0-rc.1": "compatible",
|
|
110
110
|
"0.2.0-rc.2": "compatible",
|
|
@@ -114,7 +114,8 @@
|
|
|
114
114
|
"0.2.1-alpha.4": "compatible",
|
|
115
115
|
"0.2.1-alpha.5": "compatible",
|
|
116
116
|
"0.2.1-rc.1": "compatible",
|
|
117
|
-
"0.2.1-rc.2": "compatible"
|
|
117
|
+
"0.2.1-rc.2": "compatible",
|
|
118
|
+
"0.2.1-rc.3": "compatible"
|
|
118
119
|
}
|
|
119
120
|
},
|
|
120
121
|
"bundle": {
|