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/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; stray
503
- * files from a lost config write are adopted via rename (mirrors the video
504
- * 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). */
505
596
  async function findFontFile() {
506
- const mime = (await readConfig()).fontMime ?? "font/ttf";
507
- const expected = fontPathFor(mime);
508
- if (await exists(expected)) return {
509
- path: expected,
510
- mime
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 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);
516
613
  try {
517
- await rename(p, expected);
518
- return {
519
- path: expected,
520
- mime: foundMime
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 { ...DEFAULT_CONFIG };
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 { ...DEFAULT_CONFIG };
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
- buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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 writeFile(wallpaperPath(), buf);
1142
- } catch (e) {
1143
- 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);
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
- wallpaperUrl: WALLPAPER_ROUTE
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, "video.download.tmp");
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 mime = sniffImageMime(await new Promise((resolve, reject) => {
1530
- const chunks = [];
1531
- const s = createReadStream(wallpaperPath(), {
1532
- start: 0,
1533
- end: 15
1534
- });
1535
- s.on("data", (c) => chunks.push(c));
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.enabled || rot.items.length === 0) return false;
2022
+ if (!rotationDue(rot)) return false;
1811
2023
  const now = /* @__PURE__ */ new Date();
1812
- const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
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
- const buf = await readFile(dshHomePath(DATA_DIR, ROTATION_DIR, item.file));
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
- if (firstRun) await writeConfig(config);
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: await wallpaperServeUrl(),
1853
- videoUrl: await videoUrl(),
1854
- fontUrl: await fontUrl(),
2063
+ wallpaperUrl: wpServeUrl,
2064
+ videoUrl: vidServeUrl,
2065
+ fontUrl: fntServeUrl,
1855
2066
  rotated,
1856
2067
  firstRun,
1857
- host: await hostInfo
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.9",
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": {