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/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; stray
507
- * files from a lost config write are adopted via rename (mirrors the video
508
- * slot's recovery). */
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 mime = (await readConfig()).fontMime ?? "font/ttf";
511
- const expected = fontPathFor(mime);
512
- if (await exists(expected)) return {
513
- path: expected,
514
- mime
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 foundMime = mimeForFontFile(name);
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
- await rename(p, expected);
522
- return {
523
- path: expected,
524
- mime: foundMime
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 { ...DEFAULT_CONFIG };
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 { ...DEFAULT_CONFIG };
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
- buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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 writeFile(wallpaperPath(), buf);
1146
- } catch (e) {
1147
- console.error("dsh-any-background: failed to activate a rotation wallpaper", e);
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
- wallpaperUrl: WALLPAPER_ROUTE
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, "video.download.tmp");
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 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
- }));
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.enabled || rot.items.length === 0) return false;
2022
+ if (!rotationDue(rot)) return false;
1815
2023
  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;
2024
+ const idx = pickNextRotationIndex(rot);
1822
2025
  const item = rot.items[idx];
1823
2026
  if (item === void 0) return false;
1824
2027
  try {
1825
- const buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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
- if (firstRun) await writeConfig(config);
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: await wallpaperServeUrl(),
1857
- videoUrl: await videoUrl(),
1858
- fontUrl: await fontUrl(),
2063
+ wallpaperUrl: wpServeUrl,
2064
+ videoUrl: vidServeUrl,
2065
+ fontUrl: fntServeUrl,
1859
2066
  rotated,
1860
2067
  firstRun,
1861
- host: await hostInfo
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.10",
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",