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/README.md +17 -9
- package/README.zh.md +17 -9
- package/lib/client.js +772 -240
- package/lib/client.js.map +1 -1
- package/lib/index.js +133 -37
- package/package.json +6 -2
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
|
|
543
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1481
|
-
...
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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.
|
|
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
|
},
|