dsh-any-background 0.2.9 → 0.2.10

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
@@ -536,12 +536,41 @@ async function ensureDir() {
536
536
  console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
537
537
  }
538
538
  }
539
+ let configCacheKey = null;
540
+ let configCacheValue = null;
541
+ function dropConfigCache() {
542
+ configCacheKey = null;
543
+ configCacheValue = null;
544
+ }
539
545
  async function readConfig() {
540
546
  await ensureDir();
547
+ const file = configPath();
548
+ let text;
549
+ let key;
541
550
  try {
542
- const raw = await readFile(configPath(), "utf8");
543
- return normalizeConfig(JSON.parse(raw));
551
+ const st = await stat(file);
552
+ key = {
553
+ mtimeMs: st.mtimeMs,
554
+ size: st.size
555
+ };
556
+ if (configCacheKey !== null && configCacheValue !== null && configCacheKey.mtimeMs === key.mtimeMs && configCacheKey.size === key.size) return configCacheValue;
557
+ text = await readFile(file, "utf8");
544
558
  } catch {
559
+ dropConfigCache();
560
+ return { ...DEFAULT_CONFIG };
561
+ }
562
+ try {
563
+ const parsed = normalizeConfig(JSON.parse(text));
564
+ configCacheKey = key;
565
+ configCacheValue = parsed;
566
+ return parsed;
567
+ } catch (e) {
568
+ try {
569
+ const into = `${file}.corrupt-${Date.now()}`;
570
+ await rename(file, into);
571
+ console.warn(`dsh-any-background: theme-config.json was corrupt, archived to "${into}" and reset to defaults`, e);
572
+ } catch {}
573
+ dropConfigCache();
545
574
  return { ...DEFAULT_CONFIG };
546
575
  }
547
576
  }
@@ -573,13 +602,19 @@ function warnUnknownConfigKeys(raw, normalized) {
573
602
  }
574
603
  async function writeConfig(config) {
575
604
  await ensureDir();
605
+ const tmp = `${configPath()}.tmp`;
576
606
  try {
577
607
  const normalized = normalizeConfig(config);
578
608
  warnUnknownConfigKeys(config, normalized);
579
- await writeFile(configPath(), JSON.stringify(normalized, null, 2), "utf8");
609
+ await writeFile(tmp, JSON.stringify(normalized, null, 2), "utf8");
610
+ await rename(tmp, configPath());
611
+ dropConfigCache();
580
612
  return true;
581
613
  } catch (e) {
582
614
  console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
615
+ try {
616
+ await rm(tmp, { force: true });
617
+ } catch {}
583
618
  return false;
584
619
  }
585
620
  }
@@ -618,6 +653,17 @@ function sniffImageMime(buf) {
618
653
  if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return "image/webp";
619
654
  return "image/jpeg";
620
655
  }
656
+ /** True when the leading bytes are a container the serve route knows how to
657
+ * sniff. The Content-Type only reflects what the server claims; validating the
658
+ * bytes themselves rejects a mislabeled or hostile payload with a clear error
659
+ * instead of persisting a file that renders as a broken image. */
660
+ function isImageBytes(buf) {
661
+ if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return true;
662
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return true;
663
+ if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return true;
664
+ if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return true;
665
+ return false;
666
+ }
621
667
  /** Download a wallpaper from a network URL and persist it into the local
622
668
  * wallpaper.jpg slot (replacing whatever was stored), so type switches and
623
669
  * rotation keep working through the single active slot. The response carries
@@ -690,6 +736,10 @@ async function writeWallpaperFromUrl(url) {
690
736
  error: "read failed"
691
737
  };
692
738
  }
739
+ if (!isImageBytes(buf)) return {
740
+ ok: false,
741
+ error: "not an image"
742
+ };
693
743
  await ensureDir();
694
744
  try {
695
745
  await writeFile(wallpaperPath(), buf);
@@ -911,21 +961,28 @@ async function writeVideoFromUrl(url) {
911
961
  const out = createWriteStream(tmp);
912
962
  let received = 0;
913
963
  let failed = false;
914
- const fail = () => {
915
- if (failed) return;
916
- failed = true;
917
- out.destroy();
918
- rm(tmp, { force: true });
919
- };
920
964
  const nodeStream = Readable.fromWeb(res.body);
921
965
  let idle = null;
966
+ const clearIdle = () => {
967
+ if (idle !== null) {
968
+ clearTimeout(idle);
969
+ idle = null;
970
+ }
971
+ };
922
972
  const pokeIdle = () => {
923
- if (idle) clearTimeout(idle);
973
+ clearIdle();
924
974
  idle = setTimeout(() => {
925
975
  nodeStream.destroy();
926
976
  fail();
927
977
  }, VIDEO_FETCH_IDLE_TIMEOUT);
928
978
  };
979
+ const fail = () => {
980
+ if (failed) return;
981
+ failed = true;
982
+ clearIdle();
983
+ out.destroy();
984
+ rmWhenClosed(out, tmp);
985
+ };
929
986
  nodeStream.on("data", (chunk) => {
930
987
  pokeIdle();
931
988
  received += chunk.byteLength;
@@ -934,10 +991,7 @@ async function writeVideoFromUrl(url) {
934
991
  fail();
935
992
  }
936
993
  });
937
- nodeStream.on("end", () => {
938
- if (idle) clearTimeout(idle);
939
- idle = null;
940
- });
994
+ nodeStream.on("end", clearIdle);
941
995
  nodeStream.on("aborted", fail);
942
996
  nodeStream.on("error", fail);
943
997
  out.on("error", fail);
@@ -1001,6 +1055,37 @@ async function writeVideo(dataUrl) {
1001
1055
  return false;
1002
1056
  }
1003
1057
  }
1058
+ /** Drop a partial upload's temp file once its sink is really closed. Windows
1059
+ * refuses to unlink a file that still has an open handle, so removing it in the
1060
+ * same tick as `out.destroy()` silently fails and leaves the aborted transfer
1061
+ * on disk (up to the full limit) until some later upload overwrites it. */
1062
+ function rmWhenClosed(out, tmp) {
1063
+ const drop = () => {
1064
+ rm(tmp, { force: true });
1065
+ };
1066
+ if (out.closed === true) drop();
1067
+ else out.once("close", drop);
1068
+ }
1069
+ /** Reject an oversized upload with a real status and tear the transfer down.
1070
+ * The bare req.destroy()+fail() path left the client holding a network error
1071
+ * with no way to tell "too large" from "connection died". The body is machine
1072
+ * readable (`error` + the enforced `limit`) so the panel can phrase the
1073
+ * refusal in the user's own language, with `message` kept for logs. */
1074
+ function rejectOversizedUpload(req, res, fail, limit) {
1075
+ try {
1076
+ res.writeHead(413, { "Content-Type": "application/json" });
1077
+ res.end(JSON.stringify({
1078
+ ok: false,
1079
+ error: "too large",
1080
+ limit,
1081
+ message: `too large (limit ${limit})`
1082
+ }));
1083
+ } catch {}
1084
+ fail();
1085
+ try {
1086
+ req.destroy();
1087
+ } catch {}
1088
+ }
1004
1089
  /** Stream the stored video: correct MIME, no caching, Range answers so the
1005
1090
  * browser can seek (snapshot capture does). */
1006
1091
  async function serveVideo(req, res) {
@@ -1104,16 +1189,14 @@ async function handleVideoUpload(req, res) {
1104
1189
  if (failed) return;
1105
1190
  failed = true;
1106
1191
  out.destroy();
1107
- rm(tmp, { force: true });
1192
+ rmWhenClosed(out, tmp);
1108
1193
  };
1109
1194
  req.on("aborted", fail);
1110
1195
  req.on("error", fail);
1111
1196
  req.on("data", (chunk) => {
1197
+ if (failed) return;
1112
1198
  received += chunk.byteLength;
1113
- if (received > VIDEO_UPLOAD_MAX) {
1114
- req.destroy();
1115
- fail();
1116
- }
1199
+ if (received > VIDEO_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "2 GB");
1117
1200
  });
1118
1201
  out.on("error", () => {
1119
1202
  fail();
@@ -1226,7 +1309,7 @@ async function handleWallpaperUpload(req, res) {
1226
1309
  if (failed) return;
1227
1310
  failed = true;
1228
1311
  out.destroy();
1229
- rm(tmp, { force: true });
1312
+ rmWhenClosed(out, tmp);
1230
1313
  };
1231
1314
  req.on("aborted", fail);
1232
1315
  req.on("error", fail);
@@ -1238,11 +1321,9 @@ async function handleWallpaperUpload(req, res) {
1238
1321
  } catch {}
1239
1322
  });
1240
1323
  req.on("data", (chunk) => {
1324
+ if (failed) return;
1241
1325
  received += chunk.byteLength;
1242
- if (received > WALLPAPER_UPLOAD_MAX) {
1243
- req.destroy();
1244
- fail();
1245
- }
1326
+ if (received > WALLPAPER_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
1246
1327
  });
1247
1328
  req.pipe(out);
1248
1329
  out.on("finish", async () => {
@@ -1338,7 +1419,7 @@ async function handleFontUpload(req, res) {
1338
1419
  if (failed) return;
1339
1420
  failed = true;
1340
1421
  out.destroy();
1341
- rm(tmp, { force: true });
1422
+ rmWhenClosed(out, tmp);
1342
1423
  };
1343
1424
  req.on("aborted", fail);
1344
1425
  req.on("error", fail);
@@ -1350,11 +1431,9 @@ async function handleFontUpload(req, res) {
1350
1431
  } catch {}
1351
1432
  });
1352
1433
  req.on("data", (chunk) => {
1434
+ if (failed) return;
1353
1435
  received += chunk.byteLength;
1354
- if (received > FONT_UPLOAD_MAX) {
1355
- req.destroy();
1356
- fail();
1357
- }
1436
+ if (received > FONT_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
1358
1437
  });
1359
1438
  req.pipe(out);
1360
1439
  out.on("finish", async () => {
@@ -1458,8 +1537,7 @@ function isoWeekKey(d) {
1458
1537
  * old one never reaches the screen. The client's maybeRotate stays as a
1459
1538
  * fallback and skips when this already advanced (the `rotated` flag). */
1460
1539
  async function advanceRotationIfDue() {
1461
- const cfg = await readConfig();
1462
- const rot = cfg.rotation;
1540
+ const rot = (await readConfig()).rotation;
1463
1541
  if (!rot.enabled || rot.items.length === 0) return false;
1464
1542
  const now = /* @__PURE__ */ new Date();
1465
1543
  const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
@@ -1477,12 +1555,14 @@ async function advanceRotationIfDue() {
1477
1555
  console.error("dsh-any-background: failed to advance the rotation pool", e);
1478
1556
  return false;
1479
1557
  }
1480
- cfg.rotation = {
1481
- ...rot,
1482
- current: idx,
1483
- lastRotate: now.toISOString()
1484
- };
1485
- if (!await writeConfig(cfg)) return false;
1558
+ if (!await writeConfig({
1559
+ ...await readConfig(),
1560
+ rotation: {
1561
+ ...rot,
1562
+ current: idx,
1563
+ lastRotate: now.toISOString()
1564
+ }
1565
+ })) return false;
1486
1566
  return true;
1487
1567
  }
1488
1568
  /** Dispatch one decoded RPC method to the matching persistence routine and
@@ -1588,6 +1668,22 @@ function apply(ctx) {
1588
1668
  }));
1589
1669
  return;
1590
1670
  }
1671
+ const declaredLen = Number(req.headers["content-length"] ?? "");
1672
+ if (Number.isFinite(declaredLen) && declaredLen > RPC_BODY_MAX) {
1673
+ res.writeHead(413, {
1674
+ "Content-Type": "application/json",
1675
+ connection: "close"
1676
+ });
1677
+ res.end(JSON.stringify({
1678
+ ok: false,
1679
+ error: {
1680
+ code: "dsh-any-background/too-large",
1681
+ message: `body exceeds ${RPC_BODY_MAX} bytes; uploads must use the binary routes`,
1682
+ details: {}
1683
+ }
1684
+ }));
1685
+ return;
1686
+ }
1591
1687
  const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
1592
1688
  const endpoint = pathname.startsWith(`${RPC_CHANNEL}/`) ? pathname.slice(20) : void 0;
1593
1689
  if (endpoint === void 0 || endpoint.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-background",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
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",
@@ -73,9 +73,13 @@
73
73
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
74
74
  "@deepseek-ai/dsh-client-ui-theme": "^0.1.2-alpha.2",
75
75
  "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
76
- "react": "^18.2.0"
76
+ "react": "^18.2.0",
77
+ "dsh-better-sidebar": ">=0.4.0"
77
78
  },
78
79
  "peerDependenciesMeta": {
80
+ "dsh-better-sidebar": {
81
+ "optional": true
82
+ },
79
83
  "react": {
80
84
  "optional": true
81
85
  },