dsh-any-background 0.2.8 → 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
@@ -27,10 +27,14 @@ const VIDEO_ROUTE = "/dsh-any-background/video";
27
27
  const UPLOAD_ROUTE = "/dsh-any-background/video/upload";
28
28
  const WALLPAPER_ROUTE = "/dsh-any-background/wallpaper";
29
29
  const WALLPAPER_UPLOAD_ROUTE = "/dsh-any-background/wallpaper/upload";
30
+ const FONT_ROUTE = "/dsh-any-background/font";
31
+ const FONT_UPLOAD_ROUTE = "/dsh-any-background/font/upload";
30
32
  const UPLOAD_TMP = "wallpaper.upload.tmp";
31
33
  const VIDEO_UPLOAD_TMP = "video.upload.tmp";
34
+ const FONT_UPLOAD_TMP = "font.upload.tmp";
32
35
  const WALLPAPER_UPLOAD_MAX = 104857600;
33
36
  const VIDEO_UPLOAD_MAX = 2147483648;
37
+ const FONT_UPLOAD_MAX = 104857600;
34
38
  const WALLPAPER_FETCH_MAX = 26214400;
35
39
  const WALLPAPER_FETCH_TIMEOUT = 2e4;
36
40
  const VIDEO_FETCH_MAX = 2147483648;
@@ -54,28 +58,100 @@ const VIDEO_CANDIDATES = [
54
58
  "wallpaper.mkv",
55
59
  "wallpaper.video"
56
60
  ];
61
+ /** Font slot: one font owns the slot, named by format like the video slot. */
62
+ function fontFileName(mime) {
63
+ switch (mime) {
64
+ case "font/woff2": return "font.woff2";
65
+ case "font/woff": return "font.woff";
66
+ case "font/otf": return "font.otf";
67
+ case "font/ttf": return "font.ttf";
68
+ default: return "font.ttf";
69
+ }
70
+ }
71
+ const FONT_CANDIDATES = [
72
+ "font.woff2",
73
+ "font.woff",
74
+ "font.otf",
75
+ "font.ttf"
76
+ ];
77
+ /** Sniff a font's container format from its leading magic bytes (null when the
78
+ * bytes are not a recognized font — uploads are rejected rather than stored). */
79
+ function sniffFontMime(buf) {
80
+ if (buf.length >= 4 && buf[0] === 119 && buf[1] === 79 && buf[2] === 70 && buf[3] === 50) return "font/woff2";
81
+ if (buf.length >= 4 && buf[0] === 119 && buf[1] === 79 && buf[2] === 70 && buf[3] === 70) return "font/woff";
82
+ if (buf.length >= 4 && buf[0] === 79 && buf[1] === 84 && buf[2] === 84 && buf[3] === 79) return "font/otf";
83
+ if (buf.length >= 4 && buf[0] === 0 && buf[1] === 1 && buf[2] === 0 && buf[3] === 0) return "font/ttf";
84
+ return null;
85
+ }
57
86
  const DEFAULT_CONFIG = {
58
87
  color: null,
59
88
  opacities: {
60
- bg: .85,
61
- sidebar: .93,
62
- card: 1,
63
- input: 1
89
+ bg: .5,
90
+ sidebar: .5,
91
+ card: .5,
92
+ input: .5
64
93
  },
65
94
  blurs: {
66
- bg: 0,
67
- sidebar: 0,
68
- card: 0,
69
- settings: 0,
70
- chat: 0,
71
- trajectory: 0,
72
- input: 0,
73
- panel: 0,
74
- produced: 0
95
+ bg: 30,
96
+ sidebar: 30,
97
+ card: 30,
98
+ settings: 30,
99
+ chat: 30,
100
+ trajectory: 30,
101
+ input: 30,
102
+ panel: 30,
103
+ produced: 30
75
104
  },
76
- settingsOpacity: 1,
105
+ strokes: {
106
+ bg: {
107
+ width: 0,
108
+ color: "auto",
109
+ customColor: "#808080"
110
+ },
111
+ sidebar: {
112
+ width: 0,
113
+ color: "auto",
114
+ customColor: "#808080"
115
+ },
116
+ card: {
117
+ width: 0,
118
+ color: "auto",
119
+ customColor: "#808080"
120
+ },
121
+ settings: {
122
+ width: 0,
123
+ color: "auto",
124
+ customColor: "#808080"
125
+ },
126
+ chat: {
127
+ width: 0,
128
+ color: "auto",
129
+ customColor: "#808080"
130
+ },
131
+ trajectory: {
132
+ width: 0,
133
+ color: "auto",
134
+ customColor: "#808080"
135
+ },
136
+ input: {
137
+ width: 0,
138
+ color: "auto",
139
+ customColor: "#808080"
140
+ },
141
+ panel: {
142
+ width: 0,
143
+ color: "auto",
144
+ customColor: "#808080"
145
+ },
146
+ produced: {
147
+ width: 0,
148
+ color: "auto",
149
+ customColor: "#808080"
150
+ }
151
+ },
152
+ settingsOpacity: .5,
77
153
  wallpaperOpacity: 1,
78
- blur: 0,
154
+ blur: 30,
79
155
  bgState: {
80
156
  zoom: 1,
81
157
  x: 0,
@@ -93,12 +169,14 @@ const DEFAULT_CONFIG = {
93
169
  backgroundType: "image",
94
170
  bgMode: "fit",
95
171
  videoMime: null,
172
+ fontMime: null,
173
+ fontEnabled: true,
96
174
  generatedBg: null,
97
175
  regenerateOnReload: false,
98
- chatTextOpacity: 0,
99
- trajectoryOpacity: 1,
100
- panelOpacity: 1,
101
- producedOpacity: 1,
176
+ chatTextOpacity: .5,
177
+ trajectoryOpacity: .5,
178
+ panelOpacity: .5,
179
+ producedOpacity: .5,
102
180
  profiles: [],
103
181
  rotation: {
104
182
  enabled: false,
@@ -123,6 +201,7 @@ const dataDir = () => dshHomePath(DATA_DIR);
123
201
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
124
202
  const wallpaperPath = () => dshHomePath(DATA_DIR, WALLPAPER_FILE);
125
203
  const videoPathFor = (mime) => dshHomePath(DATA_DIR, videoFileName(mime));
204
+ const fontPathFor = (mime) => dshHomePath(DATA_DIR, fontFileName(mime));
126
205
  const exists = async (p) => {
127
206
  try {
128
207
  await access(p);
@@ -162,6 +241,43 @@ async function findVideoFile() {
162
241
  function clamp(n, lo, hi, def) {
163
242
  return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
164
243
  }
244
+ /** Locate the stored font: the recorded MIME decides the expected name; stray
245
+ * files from a lost config write are adopted via rename (mirrors the video
246
+ * slot's recovery). */
247
+ async function findFontFile() {
248
+ const mime = (await readConfig()).fontMime ?? "font/ttf";
249
+ const expected = fontPathFor(mime);
250
+ if (await exists(expected)) return {
251
+ path: expected,
252
+ mime
253
+ };
254
+ for (const name of FONT_CANDIDATES) {
255
+ const p = dshHomePath(DATA_DIR, name);
256
+ if (!await exists(p)) continue;
257
+ const foundMime = mimeForFontFile(name);
258
+ try {
259
+ await rename(p, expected);
260
+ return {
261
+ path: expected,
262
+ mime: foundMime
263
+ };
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+ function mimeForFontFile(name) {
271
+ switch (name) {
272
+ case "font.woff2": return "font/woff2";
273
+ case "font.woff": return "font/woff";
274
+ case "font.otf": return "font/otf";
275
+ default: return "font/ttf";
276
+ }
277
+ }
278
+ async function fontUrl() {
279
+ return await findFontFile() ? FONT_ROUTE : null;
280
+ }
165
281
  function normalizeBgState(s) {
166
282
  return {
167
283
  zoom: clamp(s.zoom, .1, 10, 1),
@@ -172,6 +288,40 @@ function normalizeBgState(s) {
172
288
  };
173
289
  }
174
290
  /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
291
+ const STROKE_GROUPS = [
292
+ "bg",
293
+ "sidebar",
294
+ "card",
295
+ "settings",
296
+ "chat",
297
+ "trajectory",
298
+ "input",
299
+ "panel",
300
+ "produced"
301
+ ];
302
+ const STROKE_COLOR_KEYS = [
303
+ "auto",
304
+ "gray",
305
+ "black",
306
+ "white",
307
+ "theme",
308
+ "custom"
309
+ ];
310
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
311
+ function normalizeStroke(raw) {
312
+ const s = raw ?? {};
313
+ return {
314
+ width: clamp(s.width, 0, 4, 0),
315
+ color: STROKE_COLOR_KEYS.includes(s.color) ? s.color : "auto",
316
+ customColor: typeof s.customColor === "string" && HEX_RE.test(s.customColor) ? s.customColor : "#808080"
317
+ };
318
+ }
319
+ function normalizeStrokes(raw) {
320
+ const s = raw ?? {};
321
+ const out = {};
322
+ for (const k of STROKE_GROUPS) out[k] = normalizeStroke(s[k]);
323
+ return out;
324
+ }
175
325
  function normalizeConfig(raw) {
176
326
  const r = raw ?? {};
177
327
  const c = r.color;
@@ -220,6 +370,7 @@ function normalizeConfig(raw) {
220
370
  input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
221
371
  },
222
372
  blurs,
373
+ strokes: normalizeStrokes(r.strokes),
223
374
  settingsOpacity: clamp(r.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
224
375
  wallpaperOpacity: clamp(r.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
225
376
  blur: clamp(r.blur, 0, 60, DEFAULT_CONFIG.blur),
@@ -228,6 +379,8 @@ function normalizeConfig(raw) {
228
379
  backgroundType: bgType,
229
380
  bgMode,
230
381
  videoMime: typeof r.videoMime === "string" ? r.videoMime : null,
382
+ fontMime: typeof r.fontMime === "string" ? r.fontMime : null,
383
+ fontEnabled: typeof r.fontEnabled === "boolean" ? r.fontEnabled : DEFAULT_CONFIG.fontEnabled,
231
384
  generatedBg,
232
385
  regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
233
386
  chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
@@ -311,6 +464,7 @@ function normalizeProfileAppearance(raw) {
311
464
  input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
312
465
  },
313
466
  blurs,
467
+ strokes: normalizeStrokes(a.strokes),
314
468
  settingsOpacity: clamp(a.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
315
469
  wallpaperOpacity: clamp(a.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
316
470
  blur: clamp(a.blur, 0, 60, DEFAULT_CONFIG.blur),
@@ -382,12 +536,41 @@ async function ensureDir() {
382
536
  console.warn(`dsh-any-background: cannot create data dir "${dataDir()}"`, e);
383
537
  }
384
538
  }
539
+ let configCacheKey = null;
540
+ let configCacheValue = null;
541
+ function dropConfigCache() {
542
+ configCacheKey = null;
543
+ configCacheValue = null;
544
+ }
385
545
  async function readConfig() {
386
546
  await ensureDir();
547
+ const file = configPath();
548
+ let text;
549
+ let key;
387
550
  try {
388
- const raw = await readFile(configPath(), "utf8");
389
- 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");
390
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();
391
574
  return { ...DEFAULT_CONFIG };
392
575
  }
393
576
  }
@@ -406,7 +589,11 @@ function warnUnknownConfigKeys(raw, normalized) {
406
589
  if (known.has(key) || LEGACY_CONFIG_KEYS.has(key)) continue;
407
590
  warn(key);
408
591
  }
409
- for (const group of ["blurs", "opacities"]) {
592
+ for (const group of [
593
+ "blurs",
594
+ "opacities",
595
+ "strokes"
596
+ ]) {
410
597
  const got = r[group];
411
598
  if (got === null || typeof got !== "object") continue;
412
599
  const have = new Set(Object.keys(normalized[group]));
@@ -415,13 +602,19 @@ function warnUnknownConfigKeys(raw, normalized) {
415
602
  }
416
603
  async function writeConfig(config) {
417
604
  await ensureDir();
605
+ const tmp = `${configPath()}.tmp`;
418
606
  try {
419
607
  const normalized = normalizeConfig(config);
420
608
  warnUnknownConfigKeys(config, normalized);
421
- 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();
422
612
  return true;
423
613
  } catch (e) {
424
614
  console.error(`dsh-any-background: failed to write "${CONFIG_FILE}"`, e);
615
+ try {
616
+ await rm(tmp, { force: true });
617
+ } catch {}
425
618
  return false;
426
619
  }
427
620
  }
@@ -460,6 +653,17 @@ function sniffImageMime(buf) {
460
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";
461
654
  return "image/jpeg";
462
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
+ }
463
667
  /** Download a wallpaper from a network URL and persist it into the local
464
668
  * wallpaper.jpg slot (replacing whatever was stored), so type switches and
465
669
  * rotation keep working through the single active slot. The response carries
@@ -532,6 +736,10 @@ async function writeWallpaperFromUrl(url) {
532
736
  error: "read failed"
533
737
  };
534
738
  }
739
+ if (!isImageBytes(buf)) return {
740
+ ok: false,
741
+ error: "not an image"
742
+ };
535
743
  await ensureDir();
536
744
  try {
537
745
  await writeFile(wallpaperPath(), buf);
@@ -753,21 +961,28 @@ async function writeVideoFromUrl(url) {
753
961
  const out = createWriteStream(tmp);
754
962
  let received = 0;
755
963
  let failed = false;
756
- const fail = () => {
757
- if (failed) return;
758
- failed = true;
759
- out.destroy();
760
- rm(tmp, { force: true });
761
- };
762
964
  const nodeStream = Readable.fromWeb(res.body);
763
965
  let idle = null;
966
+ const clearIdle = () => {
967
+ if (idle !== null) {
968
+ clearTimeout(idle);
969
+ idle = null;
970
+ }
971
+ };
764
972
  const pokeIdle = () => {
765
- if (idle) clearTimeout(idle);
973
+ clearIdle();
766
974
  idle = setTimeout(() => {
767
975
  nodeStream.destroy();
768
976
  fail();
769
977
  }, VIDEO_FETCH_IDLE_TIMEOUT);
770
978
  };
979
+ const fail = () => {
980
+ if (failed) return;
981
+ failed = true;
982
+ clearIdle();
983
+ out.destroy();
984
+ rmWhenClosed(out, tmp);
985
+ };
771
986
  nodeStream.on("data", (chunk) => {
772
987
  pokeIdle();
773
988
  received += chunk.byteLength;
@@ -776,10 +991,7 @@ async function writeVideoFromUrl(url) {
776
991
  fail();
777
992
  }
778
993
  });
779
- nodeStream.on("end", () => {
780
- if (idle) clearTimeout(idle);
781
- idle = null;
782
- });
994
+ nodeStream.on("end", clearIdle);
783
995
  nodeStream.on("aborted", fail);
784
996
  nodeStream.on("error", fail);
785
997
  out.on("error", fail);
@@ -843,6 +1055,37 @@ async function writeVideo(dataUrl) {
843
1055
  return false;
844
1056
  }
845
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
+ }
846
1089
  /** Stream the stored video: correct MIME, no caching, Range answers so the
847
1090
  * browser can seek (snapshot capture does). */
848
1091
  async function serveVideo(req, res) {
@@ -946,16 +1189,14 @@ async function handleVideoUpload(req, res) {
946
1189
  if (failed) return;
947
1190
  failed = true;
948
1191
  out.destroy();
949
- rm(tmp, { force: true });
1192
+ rmWhenClosed(out, tmp);
950
1193
  };
951
1194
  req.on("aborted", fail);
952
1195
  req.on("error", fail);
953
1196
  req.on("data", (chunk) => {
1197
+ if (failed) return;
954
1198
  received += chunk.byteLength;
955
- if (received > VIDEO_UPLOAD_MAX) {
956
- req.destroy();
957
- fail();
958
- }
1199
+ if (received > VIDEO_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "2 GB");
959
1200
  });
960
1201
  out.on("error", () => {
961
1202
  fail();
@@ -1068,7 +1309,7 @@ async function handleWallpaperUpload(req, res) {
1068
1309
  if (failed) return;
1069
1310
  failed = true;
1070
1311
  out.destroy();
1071
- rm(tmp, { force: true });
1312
+ rmWhenClosed(out, tmp);
1072
1313
  };
1073
1314
  req.on("aborted", fail);
1074
1315
  req.on("error", fail);
@@ -1080,11 +1321,9 @@ async function handleWallpaperUpload(req, res) {
1080
1321
  } catch {}
1081
1322
  });
1082
1323
  req.on("data", (chunk) => {
1324
+ if (failed) return;
1083
1325
  received += chunk.byteLength;
1084
- if (received > WALLPAPER_UPLOAD_MAX) {
1085
- req.destroy();
1086
- fail();
1087
- }
1326
+ if (received > WALLPAPER_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
1088
1327
  });
1089
1328
  req.pipe(out);
1090
1329
  out.on("finish", async () => {
@@ -1123,6 +1362,162 @@ async function handleWallpaperUpload(req, res) {
1123
1362
  } catch {}
1124
1363
  }
1125
1364
  }
1365
+ /** Stream the stored custom font: sniffed MIME, no caching (uploads replace
1366
+ * the file in place). Fonts need no Range support — the browser fetches once. */
1367
+ async function serveFont(req, res) {
1368
+ if (req.method !== "GET" && req.method !== "HEAD") {
1369
+ res.writeHead(405, { "Content-Type": "application/json" });
1370
+ res.end(JSON.stringify({
1371
+ ok: false,
1372
+ error: "font route only serves GET/HEAD"
1373
+ }));
1374
+ return;
1375
+ }
1376
+ try {
1377
+ const found = await findFontFile();
1378
+ if (found === null) {
1379
+ res.writeHead(404);
1380
+ res.end("no custom font stored");
1381
+ return;
1382
+ }
1383
+ const st = await stat(found.path);
1384
+ res.writeHead(200, {
1385
+ "Content-Type": found.mime,
1386
+ "Content-Length": st.size,
1387
+ "Cache-Control": "no-store"
1388
+ });
1389
+ if (req.method === "HEAD") {
1390
+ res.end();
1391
+ return;
1392
+ }
1393
+ createReadStream(found.path).pipe(res);
1394
+ } catch (e) {
1395
+ console.error("dsh-any-background: failed to serve the custom font", e);
1396
+ try {
1397
+ res.writeHead(500);
1398
+ res.end();
1399
+ } catch {}
1400
+ }
1401
+ }
1402
+ /** Accept a raw font upload (POST): pipe the body into a temp file, sniff the
1403
+ * container format from its magic bytes (rejecting anything that is not a
1404
+ * recognizable font), then rename it into the format-derived slot and record
1405
+ * the MIME in the config so serve/find resolve immediately. */
1406
+ async function handleFontUpload(req, res) {
1407
+ if (req.method !== "POST") {
1408
+ res.writeHead(405);
1409
+ res.end();
1410
+ return;
1411
+ }
1412
+ try {
1413
+ await ensureDir();
1414
+ const tmp = dshHomePath(DATA_DIR, FONT_UPLOAD_TMP);
1415
+ const out = createWriteStream(tmp);
1416
+ let received = 0;
1417
+ let failed = false;
1418
+ const fail = () => {
1419
+ if (failed) return;
1420
+ failed = true;
1421
+ out.destroy();
1422
+ rmWhenClosed(out, tmp);
1423
+ };
1424
+ req.on("aborted", fail);
1425
+ req.on("error", fail);
1426
+ out.on("error", () => {
1427
+ fail();
1428
+ try {
1429
+ res.writeHead(500);
1430
+ res.end();
1431
+ } catch {}
1432
+ });
1433
+ req.on("data", (chunk) => {
1434
+ if (failed) return;
1435
+ received += chunk.byteLength;
1436
+ if (received > FONT_UPLOAD_MAX) rejectOversizedUpload(req, res, fail, "100 MB");
1437
+ });
1438
+ req.pipe(out);
1439
+ out.on("finish", async () => {
1440
+ if (failed) return;
1441
+ try {
1442
+ if (received === 0) {
1443
+ fail();
1444
+ res.writeHead(400, { "Content-Type": "application/json" });
1445
+ res.end(JSON.stringify({
1446
+ ok: false,
1447
+ error: "empty upload"
1448
+ }));
1449
+ return;
1450
+ }
1451
+ const mime = sniffFontMime(await new Promise((resolve, reject) => {
1452
+ const chunks = [];
1453
+ const s = createReadStream(tmp, {
1454
+ start: 0,
1455
+ end: 15
1456
+ });
1457
+ s.on("data", (c) => chunks.push(c));
1458
+ s.on("end", () => resolve(Buffer.concat(chunks)));
1459
+ s.on("error", reject);
1460
+ }));
1461
+ if (mime === null) {
1462
+ fail();
1463
+ res.writeHead(415, { "Content-Type": "application/json" });
1464
+ res.end(JSON.stringify({
1465
+ ok: false,
1466
+ error: "not a font (expected ttf/otf/woff/woff2)"
1467
+ }));
1468
+ return;
1469
+ }
1470
+ const target = fontPathFor(mime);
1471
+ for (const name of FONT_CANDIDATES) {
1472
+ const p = dshHomePath(DATA_DIR, name);
1473
+ if (p !== target) await rm(p, { force: true });
1474
+ }
1475
+ await rm(target, { force: true });
1476
+ await rename(tmp, target);
1477
+ const cfg = await readConfig();
1478
+ if (cfg.fontMime !== mime) {
1479
+ cfg.fontMime = mime;
1480
+ await writeConfig(cfg);
1481
+ }
1482
+ res.writeHead(200, { "Content-Type": "application/json" });
1483
+ res.end(JSON.stringify({
1484
+ ok: true,
1485
+ fontUrl: FONT_ROUTE,
1486
+ mime
1487
+ }));
1488
+ } catch (e) {
1489
+ console.error("dsh-any-background: failed to finalize the font upload", e);
1490
+ rm(tmp, { force: true });
1491
+ try {
1492
+ res.writeHead(500);
1493
+ res.end();
1494
+ } catch {}
1495
+ }
1496
+ });
1497
+ } catch (e) {
1498
+ console.error("dsh-any-background: failed to accept the font upload", e);
1499
+ try {
1500
+ res.writeHead(500);
1501
+ res.end();
1502
+ } catch {}
1503
+ }
1504
+ }
1505
+ /** Remove every stored font variant and clear the recorded MIME. */
1506
+ async function removeFontFile() {
1507
+ try {
1508
+ for (const name of FONT_CANDIDATES) await rm(dshHomePath(DATA_DIR, name), { force: true });
1509
+ await rm(dshHomePath(DATA_DIR, FONT_UPLOAD_TMP), { force: true });
1510
+ const cfg = await readConfig();
1511
+ if (cfg.fontMime !== null) {
1512
+ cfg.fontMime = null;
1513
+ await writeConfig(cfg);
1514
+ }
1515
+ return true;
1516
+ } catch (e) {
1517
+ console.error("dsh-any-background: failed to remove the custom font", e);
1518
+ return false;
1519
+ }
1520
+ }
1126
1521
  const NS = "dshAnyBackground";
1127
1522
  const RPC_CHANNEL = "/dsh-any-background";
1128
1523
  const RPC_BODY_MAX = 314572800;
@@ -1142,8 +1537,7 @@ function isoWeekKey(d) {
1142
1537
  * old one never reaches the screen. The client's maybeRotate stays as a
1143
1538
  * fallback and skips when this already advanced (the `rotated` flag). */
1144
1539
  async function advanceRotationIfDue() {
1145
- const cfg = await readConfig();
1146
- const rot = cfg.rotation;
1540
+ const rot = (await readConfig()).rotation;
1147
1541
  if (!rot.enabled || rot.items.length === 0) return false;
1148
1542
  const now = /* @__PURE__ */ new Date();
1149
1543
  const last = rot.lastRotate !== null ? new Date(rot.lastRotate) : null;
@@ -1161,12 +1555,14 @@ async function advanceRotationIfDue() {
1161
1555
  console.error("dsh-any-background: failed to advance the rotation pool", e);
1162
1556
  return false;
1163
1557
  }
1164
- cfg.rotation = {
1165
- ...rot,
1166
- current: idx,
1167
- lastRotate: now.toISOString()
1168
- };
1169
- 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;
1170
1566
  return true;
1171
1567
  }
1172
1568
  /** Dispatch one decoded RPC method to the matching persistence routine and
@@ -1177,13 +1573,18 @@ async function handleRpcMethod(endpoint, payload) {
1177
1573
  switch (method) {
1178
1574
  case "read": {
1179
1575
  const rotated = await advanceRotationIfDue();
1576
+ const config = await readConfig();
1577
+ const firstRun = !await exists(configPath());
1578
+ if (firstRun) await writeConfig(config);
1180
1579
  return {
1181
1580
  ok: true,
1182
1581
  value: {
1183
- config: await readConfig(),
1582
+ config,
1184
1583
  wallpaperUrl: await wallpaperServeUrl(),
1185
1584
  videoUrl: await videoUrl(),
1186
- rotated
1585
+ fontUrl: await fontUrl(),
1586
+ rotated,
1587
+ firstRun
1187
1588
  }
1188
1589
  };
1189
1590
  }
@@ -1219,6 +1620,10 @@ async function handleRpcMethod(endpoint, payload) {
1219
1620
  ok: true,
1220
1621
  value: await handleRotationSet(payload)
1221
1622
  };
1623
+ case "removeFont": return {
1624
+ ok: true,
1625
+ value: await removeFontFile()
1626
+ };
1222
1627
  default: return {
1223
1628
  ok: false,
1224
1629
  error: {
@@ -1263,6 +1668,22 @@ function apply(ctx) {
1263
1668
  }));
1264
1669
  return;
1265
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
+ }
1266
1687
  const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
1267
1688
  const endpoint = pathname.startsWith(`${RPC_CHANNEL}/`) ? pathname.slice(20) : void 0;
1268
1689
  if (endpoint === void 0 || endpoint.length === 0) {
@@ -1364,7 +1785,17 @@ function apply(ctx) {
1364
1785
  path: WALLPAPER_UPLOAD_ROUTE,
1365
1786
  handler: fenceUpload(handleWallpaperUpload)
1366
1787
  }), "dsh-any-background: wallpaper upload route");
1788
+ webCtx.effect(() => webCtx.webServer.register({
1789
+ kind: "prefix",
1790
+ path: FONT_ROUTE,
1791
+ handler: serveFont
1792
+ }), "dsh-any-background: font route");
1793
+ webCtx.effect(() => webCtx.webServer.register({
1794
+ kind: "exact",
1795
+ path: FONT_UPLOAD_ROUTE,
1796
+ handler: fenceUpload(handleFontUpload)
1797
+ }), "dsh-any-background: font upload route");
1367
1798
  });
1368
1799
  }
1369
1800
  //#endregion
1370
- export { apply, inject, name };
1801
+ export { DEFAULT_CONFIG, apply, inject, name };