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/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; stray
507
- * files from a lost config write are adopted via rename (mirrors the video
508
- * slot's recovery). */
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 mime = (await readConfig()).fontMime ?? "font/ttf";
511
- const expected = fontPathFor(mime);
512
- if (await exists(expected)) return {
513
- path: expected,
514
- mime
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 foundMime = mimeForFontFile(name);
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
- await rename(p, expected);
522
- return {
523
- path: expected,
524
- mime: foundMime
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 { ...DEFAULT_CONFIG };
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 { ...DEFAULT_CONFIG };
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
- buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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 writeFile(wallpaperPath(), buf);
1146
- } catch (e) {
1147
- console.error("dsh-any-background: failed to activate a rotation wallpaper", e);
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
- wallpaperUrl: WALLPAPER_ROUTE
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, "video.download.tmp");
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 mime = sniffImageMime(await new Promise((resolve, reject) => {
1534
- const chunks = [];
1535
- const s = createReadStream(wallpaperPath(), {
1536
- start: 0,
1537
- end: 15
1538
- });
1539
- s.on("data", (c) => chunks.push(c));
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.enabled || rot.items.length === 0) return false;
2024
+ if (!rotationDue(rot)) return false;
1815
2025
  const now = /* @__PURE__ */ new Date();
1816
- const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
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
- const buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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
- if (firstRun) await writeConfig(config);
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: await wallpaperServeUrl(),
1857
- videoUrl: await videoUrl(),
1858
- fontUrl: await fontUrl(),
2065
+ wallpaperUrl: wpServeUrl,
2066
+ videoUrl: vidServeUrl,
2067
+ fontUrl: fntServeUrl,
1859
2068
  rotated,
1860
2069
  firstRun,
1861
- host: await hostInfo
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.10",
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",