dsh-folia 2.3.0 → 2.4.0

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/client.js CHANGED
@@ -16168,6 +16168,254 @@ window.__ModuleLoader__.load({
16168
16168
  );
16169
16169
  }
16170
16170
 
16171
+ // ── 内联模块:src/panel-tabs-skins.js —— 「皮肤包」页签:从自建站列皮肤包 → 下载 → 装到 harness/skins → 激活(宿主端 /skins 族)(构建期注入;勿手改本段)──
16172
+ /**
16173
+ * panel-tabs-skins.js — 「皮肤包」页签:从**自建站**列皮肤包 → 下载 → 装到本地 → 激活。
16174
+ *
16175
+ * 为什么是一个**真正的 React 组件**(而不是像别的页签那样是纯渲染函数):
16176
+ * 这个页签有自己的异步状态(列表 / 加载中 / 下载中 / 错误),而页签渲染器是在
16177
+ * `renderActiveTab` 里被**条件调用**的普通函数 —— 在里面放 hooks 会违反 hooks 顺序。
16178
+ * 所以这里导出一个组件 `SkinsTabPanel`,由薄包装 `renderSkinsTab(ctx)` 挂上去:
16179
+ * 组件实例化后 hooks 是稳定的,父级重渲染也不会错位。
16180
+ *
16181
+ * 契约与别的 `panel-tabs-*.js` 相同:构建期被内联进 bundle 的工厂作用域,
16182
+ * 因此 `React` / `apiUrl` / `apiJson` / `apiPostJson` / `weT` 都是同作用域的既有名字,
16183
+ * 本文件**不 import 任何东西**。
16184
+ *
16185
+ * 数据流(全部走宿主端,浏览器不直连站点):
16186
+ * apiJson("/skins") → 索引 + 已装/在用标记
16187
+ * apiPostJson("/skins/install", {id}) → 下载 / 校验 / 解压
16188
+ * apiPostJson("/skins/activate", {id}) → 写 skin-center-active.json
16189
+ * apiPostJson("/skins/remove", {id}) → 删目录
16190
+ * apiUrl("/skins/preview?p=…") → 预览图代理
16191
+ */
16192
+
16193
+ /** 卡片网格与卡片本体的样式:内联,避免为一个页签去动全局样式表。 */
16194
+ const SKINS_STYLE = {
16195
+ grid: {
16196
+ display: "grid",
16197
+ gridTemplateColumns: "repeat(auto-fill, minmax(168px, 1fr))",
16198
+ gap: "10px",
16199
+ marginTop: "10px",
16200
+ },
16201
+ card: {
16202
+ display: "flex", flexDirection: "column", overflow: "hidden",
16203
+ border: "1px solid rgba(255,255,255,0.12)", borderRadius: "10px",
16204
+ background: "rgba(255,255,255,0.04)",
16205
+ },
16206
+ thumb: { width: "100%", aspectRatio: "4 / 3", objectFit: "cover", display: "block", background: "rgba(0,0,0,0.35)" },
16207
+ body: { padding: "8px 9px 9px", display: "flex", flexDirection: "column", gap: "4px", flex: "1 1 auto" },
16208
+ title: { fontSize: "12.5px", fontWeight: 600, lineHeight: 1.3 },
16209
+ meta: { fontSize: "11px", opacity: 0.62, lineHeight: 1.3 },
16210
+ tags: { display: "flex", flexWrap: "wrap", gap: "3px", marginTop: "2px" },
16211
+ tag: { fontSize: "10px", padding: "1px 5px", borderRadius: "999px", background: "rgba(255,255,255,0.09)", opacity: 0.75 },
16212
+ actions: { display: "flex", gap: "6px", marginTop: "6px" },
16213
+ };
16214
+
16215
+ function skinsFmtBytes(n) {
16216
+ const v = Number(n) || 0;
16217
+ if (v >= 1024 * 1024) return (v / 1024 / 1024).toFixed(1) + " MB";
16218
+ if (v >= 1024) return Math.round(v / 1024) + " KB";
16219
+ return v + " B";
16220
+ }
16221
+
16222
+ function skinsErrText(code) {
16223
+ if (!code) return "";
16224
+ if (code === "disabled") return weT("站点数据源已关闭(DSH_FOLIA_SKINS_SITE=off)");
16225
+ if (code === "timeout") return weT("站点超时,点「刷新」重试");
16226
+ if (code === "tls") return weT("站点证书校验失败");
16227
+ if (code === "network") return weT("连不上站点(网络或域名问题)");
16228
+ if (/^http-/.test(code)) return weT("站点返回错误:") + code.slice(5);
16229
+ if (code === "json") return weT("站点索引格式不对");
16230
+ return code;
16231
+ }
16232
+
16233
+ function SkinsTabPanel() {
16234
+ const [list, setList] = React.useState([]);
16235
+ const [active, setActive] = React.useState(null);
16236
+ const [loading, setLoading] = React.useState(true);
16237
+ const [error, setError] = React.useState(null);
16238
+ const [harnessFound, setHarnessFound] = React.useState(true);
16239
+ const [busy, setBusy] = React.useState(""); // 正在安装 / 激活的那个 id
16240
+ const [note, setNote] = React.useState(null); // 一次性提示(成功 / 失败原因)
16241
+ const [q, setQ] = React.useState("");
16242
+
16243
+ const load = React.useCallback(async (force) => {
16244
+ setLoading(true);
16245
+ setError(null);
16246
+ try {
16247
+ const r = await apiJson("/skins" + (force ? "?refresh=1" : ""));
16248
+ setList((r && r.skins) || []);
16249
+ setActive((r && r.active) || null);
16250
+ setError((r && r.error) || null);
16251
+ setHarnessFound(!r || r.harnessFound !== false);
16252
+ } catch (e) {
16253
+ setError("network");
16254
+ setList([]);
16255
+ } finally {
16256
+ setLoading(false);
16257
+ }
16258
+ }, []);
16259
+
16260
+ React.useEffect(() => { load(false); }, [load]);
16261
+
16262
+ async function act(kind, id, label) {
16263
+ if (busy) return;
16264
+ setBusy(id);
16265
+ setNote(null);
16266
+ try {
16267
+ const r = await apiPostJson("/skins/" + kind, { id });
16268
+ if (r && r.ok) {
16269
+ if (kind === "install") {
16270
+ setNote({ kind: "ok", text: weT("已安装:") + label });
16271
+ await load(true);
16272
+ } else if (kind === "activate") {
16273
+ setActive(id);
16274
+ setNote({ kind: "ok", text: weT("已切换到「") + label + weT("」,重启 DSH 后生效") });
16275
+ await load(true);
16276
+ } else if (kind === "remove") {
16277
+ setNote({ kind: "ok", text: weT("已删除:") + label });
16278
+ await load(true);
16279
+ }
16280
+ } else {
16281
+ const code = (r && r.error) || "failed";
16282
+ const detail = (r && r.detail) ? "(" + r.detail + ")" : "";
16283
+ setNote({ kind: "err", text: weT("操作失败:") + code + detail });
16284
+ }
16285
+ } catch (e) {
16286
+ setNote({ kind: "err", text: weT("请求失败:") + (e && e.message ? e.message : weT("网络错误")) });
16287
+ } finally {
16288
+ setBusy("");
16289
+ }
16290
+ }
16291
+
16292
+ const needle = q.trim().toLowerCase();
16293
+ const shown = needle
16294
+ ? list.filter((s) => (s.name + " " + s.nameEn + " " + s.id + " " + (s.tags || []).join(" "))
16295
+ .toLowerCase().indexOf(needle) !== -1)
16296
+ : list;
16297
+
16298
+ const installedCount = list.filter((s) => s.installed).length;
16299
+
16300
+ const header = React.createElement("div", { className: "we-picker__section-head" },
16301
+ React.createElement("span", { className: "we-picker__section-label" },
16302
+ weT("🎨 皮肤包") + (list.length ? " · " + list.length : "")),
16303
+ React.createElement("button", {
16304
+ type: "button",
16305
+ className: "we-picker__btn",
16306
+ style: { marginLeft: "auto" },
16307
+ disabled: loading,
16308
+ onClick: () => load(true),
16309
+ title: weT("重新从站点拉取列表"),
16310
+ }, loading ? weT("加载中…") : weT("刷新")),
16311
+ );
16312
+
16313
+ if (!harnessFound) {
16314
+ return React.createElement(React.Fragment, null,
16315
+ React.createElement("div", { className: "we-picker__section" }, header,
16316
+ React.createElement("div", { className: "we-picker__empty" },
16317
+ React.createElement("span", { className: "we-picker__empty-title" }, weT("找不到 DSH 的 skins 目录")),
16318
+ React.createElement("span", { className: "we-picker__hint" },
16319
+ weT("皮肤包要装到 DSH 的 harness/skins 下。可用环境变量 DSH_FOLIA_HARNESS 指定该目录")))));
16320
+ }
16321
+
16322
+ const children = [React.createElement("div", { className: "we-picker__section", key: "sec" }, header)];
16323
+ if (error) {
16324
+ children.push(React.createElement("div", {
16325
+ key: "err",
16326
+ className: "we-picker__hint",
16327
+ style: { color: "#ff9a9a", margin: "6px 0 10px" },
16328
+ }, skinsErrText(error)));
16329
+ }
16330
+ if (note) {
16331
+ children.push(React.createElement("div", {
16332
+ key: "note",
16333
+ className: "we-picker__hint",
16334
+ style: { color: note.kind === "err" ? "#ff9a9a" : "#8ee6a8", margin: "6px 0 10px" },
16335
+ }, note.text));
16336
+ }
16337
+ children.push(React.createElement("div", {
16338
+ key: "search",
16339
+ style: { display: "flex", gap: "8px", alignItems: "center", margin: "2px 0 4px" },
16340
+ },
16341
+ React.createElement("input", {
16342
+ type: "text",
16343
+ value: q,
16344
+ placeholder: weT("搜索名称 / 作者 / 标签"),
16345
+ className: "we-picker__search",
16346
+ onChange: (e) => setQ(e.target.value),
16347
+ style: { flex: "1 1 auto" },
16348
+ }),
16349
+ React.createElement("span", { className: "we-picker__hint" },
16350
+ weT("已装 ") + installedCount + " / " + list.length),
16351
+ ));
16352
+
16353
+ if (loading && list.length === 0) {
16354
+ children.push(React.createElement("div", { key: "loading", className: "we-picker__empty" },
16355
+ React.createElement("span", { className: "we-picker__empty-title" }, weT("正在从站点拉取皮肤包…"))));
16356
+ } else if (!error && shown.length === 0) {
16357
+ children.push(React.createElement("div", { key: "empty", className: "we-picker__empty" },
16358
+ React.createElement("span", { className: "we-picker__empty-title" }, weT("没有匹配的皮肤包")),
16359
+ React.createElement("span", { className: "we-picker__hint" }, weT("换个关键词,或点「刷新」重新拉取"))));
16360
+ }
16361
+
16362
+ if (shown.length > 0) {
16363
+ children.push(React.createElement("div", { key: "grid", style: SKINS_STYLE.grid },
16364
+ shown.map((s) => {
16365
+ const previewRel = s.previewLight || s.previewDark || "";
16366
+ const previewSrc = previewRel
16367
+ ? apiUrl("/skins/preview?p=" + encodeURIComponent(previewRel))
16368
+ : "";
16369
+ const isBusy = busy === s.id;
16370
+ const btn = (label, onClick, disabled, primary) => React.createElement("button", {
16371
+ type: "button",
16372
+ className: "we-picker__btn" + (primary ? " we-picker__btn--primary" : ""),
16373
+ style: { flex: "1 1 0", fontSize: "11.5px", padding: "4px 6px" },
16374
+ disabled: Boolean(disabled) || Boolean(busy),
16375
+ onClick,
16376
+ }, label);
16377
+
16378
+ const actions = [];
16379
+ if (!s.installed) {
16380
+ actions.push(btn(isBusy ? weT("安装中…") : weT("下载安装"), () => act("install", s.id, s.name), false, true));
16381
+ } else {
16382
+ if (!s.active) {
16383
+ actions.push(btn(isBusy ? weT("切换中…") : weT("使用"), () => act("activate", s.id, s.name), false, true));
16384
+ actions.push(btn(weT("删除"), () => act("remove", s.id, s.name), isBusy, false));
16385
+ } else {
16386
+ actions.push(btn(weT("使用中"), () => {}, true, false));
16387
+ }
16388
+ }
16389
+
16390
+ return React.createElement("div", { key: s.id, style: SKINS_STYLE.card },
16391
+ previewSrc
16392
+ ? React.createElement("img", {
16393
+ src: previewSrc, alt: s.name, loading: "lazy", draggable: false, style: SKINS_STYLE.thumb,
16394
+ })
16395
+ : React.createElement("div", { style: SKINS_STYLE.thumb }),
16396
+ React.createElement("div", { style: SKINS_STYLE.body },
16397
+ React.createElement("div", { style: SKINS_STYLE.title }, s.name || s.id),
16398
+ React.createElement("div", { style: SKINS_STYLE.meta },
16399
+ (s.author ? s.author : weT("未知作者")) + " · " + skinsFmtBytes(s.zipBytes)),
16400
+ s.tagline ? React.createElement("div", { style: SKINS_STYLE.meta }, s.tagline) : null,
16401
+ (s.tags || []).length
16402
+ ? React.createElement("div", { style: SKINS_STYLE.tags },
16403
+ s.tags.slice(0, 4).map((t) => React.createElement("span", { key: t, style: SKINS_STYLE.tag }, t)))
16404
+ : null,
16405
+ React.createElement("div", { style: SKINS_STYLE.actions }, actions),
16406
+ ),
16407
+ );
16408
+ })));
16409
+ }
16410
+
16411
+ return React.createElement(React.Fragment, null, children);
16412
+ }
16413
+
16414
+ /** 页签薄包装:`renderActiveTab` 只认这个签名(与别的 `render*Tab(ctx)` 一致)。 */
16415
+ function renderSkinsTab(ctx) {
16416
+ return React.createElement(SkinsTabPanel, null);
16417
+ }
16418
+
16171
16419
  // ── 内联模块:src/picker-model.js —— 选择器模型层:过滤判定 + 派生数据 + 分页切片(纯函数 / 显式入参 / 零 DOM)(构建期注入;勿手改本段)──
16172
16420
  /**
16173
16421
  * picker-model.js — 壁纸选择器的**模型层**:过滤判定 + 派生数据 + 分页切片。
@@ -26759,6 +27007,9 @@ window.__ModuleLoader__.load({
26759
27007
  const PICKER_TAB_KEY = "dsh-folia:picker-tab";
26760
27008
  const PICKER_TABS = [
26761
27009
  { id: "library", get label() { return weT("壁纸库"); } },
27010
+ // 「皮肤包」挨着「壁纸库」:两者都是"给背景换东西",用户心智在同一处。
27011
+ // 它列的是**自建站**上的皮肤包(宿主端 /skins 族),不是皮肤中心的内置列表。
27012
+ { id: "skins", get label() { return weT("皮肤包"); } },
26762
27013
  { id: "appearance", get label() { return weT("外观"); } },
26763
27014
  { id: "playback", get label() { return weT("播放"); } },
26764
27015
  { id: "system", get label() { return weT("系统"); } },
@@ -29375,6 +29626,9 @@ window.__ModuleLoader__.load({
29375
29626
  onReopenNotice: () => { noticeReopenRequest = true; emit(); },
29376
29627
  });
29377
29628
  if (activeTab === "extensions") return renderExtensionsTab(extensionCtx());
29629
+ // 「皮肤包」自带异步状态(列表 / 下载中 / 错误),所以它是一个**真组件**而不是
29630
+ // 纯渲染函数 —— 薄包装只负责挂上去(理由见 src/panel-tabs-skins.js 文件头)。
29631
+ if (activeTab === "skins") return renderSkinsTab();
29378
29632
  if (activeTab === "appearance") return renderAppearanceTab({
29379
29633
  setSetting, setTransient,
29380
29634
  fontSet: fontSetCtx(),
package/lib/index.js CHANGED
@@ -94,6 +94,7 @@ import { registerPropsRoutes } from './routes/props.js';
94
94
  import { registerLiveFrameRoutes } from './routes/live-frame.js';
95
95
  import { registerSettingsRoutes } from './routes/settings.js';
96
96
  import { registerCacheDirRoutes } from './routes/cache-dir.js';
97
+ import { registerSkinRoutes } from './routes/skins.js';
97
98
  // C8′/C6 出户的组件行(P2-1 / P2-2):组件入口**静态** import(不是动态)—— `test/verify-reachability.mjs`
98
99
  // 只从 ENTRY 沿 import 边走,动态加载的组件入口会被判定为不可达,棘轮当场变红。
99
100
  // 组件名 → 实现表的登记在 `applyComponent` **函数体内**(P2-1b 起;模块级常量会让门面求值期
@@ -495,6 +496,14 @@ export function apply(ctx) {
495
496
  normalizeUserDir, cacheBaseDir, setCacheDir, armBodyIdleTimeout,
496
497
  });
497
498
 
499
+ // 7c. 「皮肤包」数据源(自建站)→ lib/routes/skins.js。一条 prefix 注册带五个动作
500
+ // (索引 / 安装 / 激活 / 卸载 / 预览图代理)。
501
+ // ⚠️ 渲染不在这里:落到 `harness/skins/<id>/` 之后,应用样式与 hooks 的仍是
502
+ // 皮肤中心(dsh-skin-center)。本族只负责"取、验、落、切"。
503
+ registerSkinRoutes(webServer, {
504
+ disposers, base: BASE, log,
505
+ });
506
+
498
507
  // fiber disposer 的语义(先逐个 disposer、再 releaseKernelLease(hostKernel)——内核引用计数
499
508
  // 归零才 `transcodeKit.dispose()`——再 destroy 在途流、清 activeStreams / mediaMap)
500
509
  // 逐字搬进 lib/kernel/host-session.js 的 `dispose()`。
@@ -0,0 +1,523 @@
1
+ /**
2
+ * routes/skins.js — 「皮肤包」数据源:把**自建站点**上的皮肤包列出来、下下来、装上、切换。
3
+ *
4
+ * 与 `routes/inventory.js` 的站点数据源是**同一套思路**(自建站 = 唯一真源,宿主端代取,
5
+ * 浏览器不直连),但落地对象不同:那边是壁纸条目,这边是**整套皮肤包**。
6
+ *
7
+ * 为什么这件事可行而不是"另造一套皮肤引擎":
8
+ * · 站点已经按市场同一格式产出索引(`<site>/skins/manifest.json`)与 zip
9
+ * (`<site>/skins/<id>.zip`),zip 内就是 `skin.json` + `skin.css` + `assets/`
10
+ * + `dsh-market.provenance.json`;
11
+ * · 本插件**不负责渲染** —— 皮肤中心(dsh-skin-center)读 `harness/skins/<id>/`
12
+ * 自己应用样式与 hooks。我们只做"取、验、落、切"四件事。
13
+ * · 所以皮肤中心的完整性校验仍会照常跑(provenance 在 zip 内原样带着,哈希没被动过)。
14
+ *
15
+ * 四个路由(全部 `${base}/skins` 前缀下,一条 prefix 注册):
16
+ * GET /skins 索引 + 本机已装/在用状态(合并后下发,客户端一次拿全)
17
+ * POST /skins/install {id} 下载 → sha256 校验 → 解压到 harness/skins/<id>/
18
+ * POST /skins/activate {id} 写 harness/skin-center-active.json 的 `active`
19
+ * POST /skins/remove {id} 删目录(在用 / 不存在的拒绝)
20
+ *
21
+ * 纪律(与库存族同口径):
22
+ * · 站点不可达**绝不影响**插件其他功能:索引失败返回 503 + 结构化错误,客户端显示"不可达";
23
+ * · 每次下载都有上限(`SKIN_ZIP_MAX_BYTES`)与超时;
24
+ * · zip 解压是**纯 JS**(node:zlib)—— 不引入新依赖,也不 shell 出去调 Expand-Archive;
25
+ * · 解压路径做 zip-slip 防护:绝对路径、`..`、反斜杠、盘符一律拒绝;
26
+ * · 只认 `^[a-z0-9][a-z0-9-]*$` 的 id(索引来自远端,不能拿它当路径片段直接拼)。
27
+ */
28
+ import { createHash } from 'node:crypto';
29
+ import { inflateRawSync } from 'node:zlib';
30
+ import {
31
+ cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
32
+ } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join, resolve, sep } from 'node:path';
35
+ import { sendJson } from '../json-response.js';
36
+ import { bodyReader } from '../http-body.js';
37
+
38
+ /* ── 站点 ────────────────────────────────────────────────────────────────── */
39
+
40
+ const SKINS_SITE = 'https://folia-major-3t6.pages.dev';
41
+ const SKINS_INDEX_PATH = '/skins/manifest.json';
42
+ const SKINS_TTL_MS = 60000;
43
+ const SKINS_TIMEOUT_MS = 8000;
44
+ /** 单个 zip 上限:站点上最大的是 8 MB 那套(whalechan-harness),留足余量。 */
45
+ const SKIN_ZIP_MAX_BYTES = 32 * 1024 * 1024;
46
+ /** 分卷拼接后的总上限(DIY 手账本 3 段合计约 67 MB,留余量)。 */
47
+ const SKIN_TOTAL_MAX_BYTES = 128 * 1024 * 1024;
48
+ const CONTROL_JSON_MAX_BYTES = 64 * 1024;
49
+ const ACTIVE_FILE = 'skin-center-active.json';
50
+ const SKINS_DIR_NAME = 'skins';
51
+ const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
52
+
53
+ /** `DSH_FOLIA_SKINS_SITE` → 归一化基址;`off`/空串表示停用整条通道。 */
54
+ function skinsSiteBase() {
55
+ const raw = process.env.DSH_FOLIA_SKINS_SITE;
56
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
57
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : SKINS_SITE;
58
+ return base.replace(/\/+$/, '');
59
+ }
60
+
61
+ /** @type {{ at: number, payload: any[] | null, error: string | null }} */
62
+ let indexCache = { at: 0, payload: null, error: null };
63
+
64
+ /**
65
+ * harness 根目录(`skins/` 与 `skin-center-active.json` 所在的那一层)。
66
+ *
67
+ * 桌面版是 `<userData>/harness`(Electron 的 `--user-data-dir` 是 `%APPDATA%\dsh-desktop`);
68
+ * CLI 版是 `~/.dsh`。两边都没有权威 API 可问,所以按候选逐个试,**以 `skins/` 或
69
+ * `skin-center-active.json` 是否存在为准**。`DSH_FOLIA_HARNESS` 可强制指定(自检/异形安装)。
70
+ * @returns {string | null}
71
+ */
72
+ export function harnessRoot() {
73
+ const forced = process.env.DSH_FOLIA_HARNESS;
74
+ if (typeof forced === 'string' && forced.trim()) return resolve(forced.trim());
75
+ const home = homedir();
76
+ /** @type {string[]} */
77
+ const candidates = [];
78
+ const appData = process.env.APPDATA;
79
+ const localAppData = process.env.LOCALAPPDATA;
80
+ if (appData) candidates.push(join(appData, 'dsh-desktop', 'harness'));
81
+ if (localAppData) candidates.push(join(localAppData, 'dsh-desktop', 'harness'));
82
+ candidates.push(join(home, '.dsh', 'harness'), join(home, '.dsh'), join(home, '.dsh-desktop', 'harness'));
83
+ for (const c of candidates) {
84
+ try {
85
+ if (existsSync(join(c, SKINS_DIR_NAME)) || existsSync(join(c, ACTIVE_FILE))) return c;
86
+ } catch { /* 单个候选读不到 ⇒ 试下一个 */ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /* ── 索引 ────────────────────────────────────────────────────────────────── */
92
+
93
+ /**
94
+ * 拉站点索引并**精简**成客户端要的形状。永不抛;失败返回 `{ items: [], error }`。
95
+ *
96
+ * 为什么精简:原始 manifest 62 KB / 65 条,每条还带完整 description 与 parts 清单;
97
+ * 客户端一次拿全没有意义,只留卡片需要的字段 + `zipRelative`(安装时回传给宿主)。
98
+ */
99
+ async function fetchIndex(force = false) {
100
+ const base = skinsSiteBase();
101
+ if (!base) return { items: [], error: 'disabled' };
102
+ if (!force && Date.now() - indexCache.at < SKINS_TTL_MS) {
103
+ return { items: indexCache.payload || [], error: indexCache.error };
104
+ }
105
+
106
+ let items = [];
107
+ let error = null;
108
+ try {
109
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
110
+ ? AbortSignal.timeout(SKINS_TIMEOUT_MS)
111
+ : undefined;
112
+ const res = await fetch(base + SKINS_INDEX_PATH, { headers: { accept: 'application/json' }, signal });
113
+ if (!res.ok) {
114
+ error = 'http-' + res.status;
115
+ } else {
116
+ let doc;
117
+ try {
118
+ doc = await res.json();
119
+ } catch {
120
+ error = 'json';
121
+ }
122
+ if (doc) {
123
+ const list = Array.isArray(doc.skins) ? doc.skins : [];
124
+ for (const s of list) {
125
+ const id = String((s && s.id) || '');
126
+ if (!ID_RE.test(id)) continue;
127
+ // 分卷包(DIY 手账本那种 67 MB 三段):`parts` 是 **对象数组** `{name, bytes}`,
128
+ // 也兼容纯字符串形态 —— 早期实现只认字符串,把 diy-journal 整条静默丢了
129
+ //(manifest 65 条、列表只出 64 条,就是它)。
130
+ const parts = (Array.isArray(s.parts) ? s.parts : [])
131
+ .map((p) => (typeof p === 'string' ? p : (p && typeof p.name === 'string' ? p.name : '')))
132
+ .filter(Boolean);
133
+ const zip = typeof s.zip === 'string' ? s.zip : '';
134
+ if (!zip && parts.length === 0) continue;
135
+ items.push({
136
+ id,
137
+ name: String(s.name || id),
138
+ nameEn: String(s.nameEn || ''),
139
+ version: String(s.version || ''),
140
+ author: String(s.author || ''),
141
+ accent: typeof s.accent === 'string' ? s.accent : '',
142
+ tagline: String(s.tagline || ''),
143
+ tags: Array.isArray(s.tags) ? s.tags.slice(0, 8).map(String) : [],
144
+ zipBytes: Number(s.zipBytes) || 0,
145
+ sha256: typeof s.sha256 === 'string' ? s.sha256 : '',
146
+ zipRelative: zip,
147
+ parts: parts.slice(0, 16),
148
+ previewLight: (s.preview && typeof s.preview.light === 'string') ? s.preview.light : '',
149
+ previewDark: (s.preview && typeof s.preview.dark === 'string') ? s.preview.dark : '',
150
+ });
151
+ }
152
+ }
153
+ }
154
+ } catch (e) {
155
+ const code = String((e && (e.name || e.code)) || '');
156
+ const msg = String((e && e.message) || '');
157
+ if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) error = 'timeout';
158
+ else if (/cert|tls|ssl/i.test(msg)) error = 'tls';
159
+ else error = 'network';
160
+ }
161
+
162
+ indexCache = { at: Date.now(), payload: items, error };
163
+ return { items, error };
164
+ }
165
+
166
+ /* ── 本机状态 ─────────────────────────────────────────────────────────────── */
167
+
168
+ /** 本机已装的皮肤 id 集合(`skins/` 下的目录名)。 */
169
+ function installedIds(harness) {
170
+ const dir = join(harness, SKINS_DIR_NAME);
171
+ try {
172
+ return new Set(readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
173
+ } catch {
174
+ return new Set();
175
+ }
176
+ }
177
+
178
+ /** 皮肤中心当前的 `active`(读不到就当没有 —— fail-open,不影响列表)。 */
179
+ function activeSkinId(harness) {
180
+ try {
181
+ const doc = JSON.parse(readFileSync(join(harness, ACTIVE_FILE), 'utf8'));
182
+ return typeof doc.active === 'string' ? doc.active : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /* ── zip 解压(纯 JS,零依赖)────────────────────────────────────────────── */
189
+
190
+ /** 从中央目录读条目表。只支持 store(0) / deflate(8),够用且不引依赖。 */
191
+ function zipEntries(buf) {
192
+ // 找 EOCD(0x06054b50),从尾部往前扫(注释区最多 65535 字节)
193
+ let eocd = -1;
194
+ for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65558; i--) {
195
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
196
+ }
197
+ if (eocd < 0) throw new Error('不是有效的 zip(找不到中央目录)');
198
+ const count = buf.readUInt16LE(eocd + 10);
199
+ let off = buf.readUInt32LE(eocd + 16);
200
+ const out = [];
201
+ for (let i = 0; i < count; i++) {
202
+ if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('中央目录条目损坏');
203
+ const method = buf.readUInt16LE(off + 10);
204
+ const compSize = buf.readUInt32LE(off + 20);
205
+ const nameLen = buf.readUInt16LE(off + 28);
206
+ const extraLen = buf.readUInt16LE(off + 30);
207
+ const commentLen = buf.readUInt16LE(off + 32);
208
+ const localOff = buf.readUInt32LE(off + 42);
209
+ const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
210
+ out.push({ name, method, compSize, localOff });
211
+ off += 46 + nameLen + extraLen + commentLen;
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** 校验条目名是安全的相对路径,并返回归一化后的相对段。 */
217
+ function safeRelPath(name) {
218
+ const norm = String(name).replace(/\\/g, '/');
219
+ if (!norm || norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) return null;
220
+ const segs = norm.split('/').filter((s) => s && s !== '.');
221
+ if (segs.length === 0) return null;
222
+ for (const s of segs) {
223
+ if (s === '..') return null;
224
+ if (s.includes('\0')) return null;
225
+ }
226
+ return segs;
227
+ }
228
+
229
+ /**
230
+ * 解压到 `destDir`。**先解到临时目录再整体改名**,中途失败不留半个皮肤。
231
+ * @returns {{ files: number }}
232
+ */
233
+ function extractZipTo(buf, destDir, tmpDir) {
234
+ const entries = zipEntries(buf);
235
+ let files = 0;
236
+ for (const e of entries) {
237
+ const segs = safeRelPath(e.name);
238
+ if (!segs) throw new Error('zip 内含不安全路径:' + e.name);
239
+ // 本地文件头长度可变(name/extra 可能与中央目录不同),必须重读
240
+ const lo = e.localOff;
241
+ if (buf.readUInt32LE(lo) !== 0x04034b50) throw new Error('本地文件头损坏:' + e.name);
242
+ const lNameLen = buf.readUInt16LE(lo + 26);
243
+ const lExtraLen = buf.readUInt16LE(lo + 28);
244
+ const dataStart = lo + 30 + lNameLen + lExtraLen;
245
+ const raw = buf.subarray(dataStart, dataStart + e.compSize);
246
+ const isDir = e.name.endsWith('/');
247
+ const target = join(tmpDir, ...segs);
248
+ // 兜底:拼接后必须仍在 tmpDir 里(防 zip-slip 的任何漏网)
249
+ if (!resolve(target).startsWith(resolve(tmpDir) + sep)) throw new Error('zip 路径越界:' + e.name);
250
+ if (isDir) {
251
+ mkdirSync(target, { recursive: true });
252
+ continue;
253
+ }
254
+ let data;
255
+ if (e.method === 0) data = raw;
256
+ else if (e.method === 8) data = inflateRawSync(raw);
257
+ else throw new Error('不支持的 zip 压缩方式 ' + e.method + ':' + e.name);
258
+ mkdirSync(join(target, '..'), { recursive: true });
259
+ writeFileSync(target, data);
260
+ files++;
261
+ }
262
+ if (files === 0) throw new Error('zip 里没有任何文件');
263
+ return { files };
264
+ }
265
+
266
+ /* ── 下载 ────────────────────────────────────────────────────────────────── */
267
+
268
+ async function fetchBytes(url, maxBytes) {
269
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
270
+ ? AbortSignal.timeout(SKINS_TIMEOUT_MS * 4) // 8 MB 的包在跨境链路上要给足时间
271
+ : undefined;
272
+ const res = await fetch(url, { headers: { accept: '*/*' }, signal });
273
+ if (!res.ok) throw new Error('下载失败:HTTP ' + res.status);
274
+ const len = Number(res.headers.get('content-length'));
275
+ if (Number.isFinite(len) && len > maxBytes) throw new Error('皮肤包过大:' + len + ' 字节');
276
+ const ab = await res.arrayBuffer();
277
+ const buf = Buffer.from(ab);
278
+ if (buf.length > maxBytes) throw new Error('皮肤包过大:' + buf.length + ' 字节');
279
+ if (buf.length === 0) throw new Error('下载到 0 字节');
280
+ return buf;
281
+ }
282
+
283
+ function sha256Hex(buf) {
284
+ return createHash('sha256').update(buf).digest('hex');
285
+ }
286
+
287
+ /* ── 路由 ────────────────────────────────────────────────────────────────── */
288
+
289
+ /**
290
+ * @param {any} webServer
291
+ * @param {{ disposers: Function[], base: string, log?: Function }} c
292
+ */
293
+ export function registerSkinRoutes(webServer, c) {
294
+ const { disposers, base, log } = c;
295
+ const note = typeof log === 'function' ? log : () => {};
296
+
297
+ /**
298
+ * 读一个小 JSON body({id})并校验成合法皮肤 id。解析失败 / 缺 id / 越界一律 null ⇒ 调用方回 400。
299
+ *
300
+ * ⚠️ `bodyReader` 是「处理器 + 取值器」模式(见 lib/http-body.js 文件头的用法),**没有**
301
+ * `await reader.text()` 这种异步形态 —— 监听器由调用点挂,这里包一层 Promise 收口。
302
+ */
303
+ function readIdBody(req) {
304
+ return new Promise((resolveId) => {
305
+ let settled = false;
306
+ const done = (v) => { if (!settled) { settled = true; resolveId(v); } };
307
+ const reader = bodyReader(req, {
308
+ maxBytes: CONTROL_JSON_MAX_BYTES,
309
+ // 越限当场判失败:body 不该继续占内存,也不该等到 end 才应 400。
310
+ onOverflow: () => done(null),
311
+ });
312
+ req.on('data', reader.onData);
313
+ req.on('end', () => {
314
+ if (reader.overflowed) { done(null); return; }
315
+ let doc;
316
+ try {
317
+ doc = JSON.parse(reader.text());
318
+ } catch {
319
+ done(null);
320
+ return;
321
+ }
322
+ const id = doc && typeof doc.id === 'string' ? doc.id : '';
323
+ done(ID_RE.test(id) ? id : null);
324
+ });
325
+ req.on('error', () => done(null));
326
+ });
327
+ }
328
+
329
+ disposers.push(webServer.register({
330
+ kind: 'exact',
331
+ path: `${base}/skins`,
332
+ handler: async (req, res) => {
333
+ const url = new URL(req.url || '/', 'http://x');
334
+ const force = url.searchParams.get('refresh') === '1';
335
+ const harness = harnessRoot();
336
+ const { items, error } = await fetchIndex(force);
337
+ const installed = harness ? installedIds(harness) : new Set();
338
+ const active = harness ? activeSkinId(harness) : null;
339
+ const merged = items.map((it) => ({
340
+ ...it,
341
+ installed: installed.has(it.id),
342
+ active: it.id === active,
343
+ }));
344
+ sendJson(res, 200, {
345
+ ok: error === null,
346
+ error,
347
+ site: skinsSiteBase(),
348
+ harness,
349
+ harnessFound: Boolean(harness),
350
+ active,
351
+ count: merged.length,
352
+ installedCount: [...installed].length,
353
+ skins: merged,
354
+ });
355
+ },
356
+ }));
357
+
358
+ disposers.push(webServer.register({
359
+ kind: 'exact',
360
+ path: `${base}/skins/install`,
361
+ handler: async (req, res) => {
362
+ const id = await readIdBody(req);
363
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
364
+ const harness = harnessRoot();
365
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
366
+ const site = skinsSiteBase();
367
+ if (!site) { sendJson(res, 503, { ok: false, error: 'disabled' }); return; }
368
+
369
+ const { items } = await fetchIndex();
370
+ const entry = items.find((x) => x.id === id);
371
+ if (!entry) { sendJson(res, 404, { ok: false, error: 'unknown-skin' }); return; }
372
+
373
+ const skinsDir = join(harness, SKINS_DIR_NAME);
374
+ const destDir = join(skinsDir, id);
375
+ const tmpDir = join(skinsDir, '.' + id + '.tmp-' + Date.now().toString(36));
376
+ try {
377
+ // 分卷包:按顺序拼接成完整 zip
378
+ let buf;
379
+ if (entry.parts.length > 0) {
380
+ const chunks = [];
381
+ for (const p of entry.parts) {
382
+ chunks.push(await fetchBytes(site + '/' + String(p).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES));
383
+ }
384
+ buf = Buffer.concat(chunks);
385
+ // 拼接后总量另设一道闸:单包 32 MB 的上限管不住 3 段拼起来的 67 MB。
386
+ if (buf.length > SKIN_TOTAL_MAX_BYTES) {
387
+ throw new Error('分卷拼接后过大:' + buf.length + ' 字节');
388
+ }
389
+ } else {
390
+ buf = await fetchBytes(site + '/' + String(entry.zipRelative).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES);
391
+ }
392
+
393
+ // 哈希校验:manifest 里给的是**截断**哈希(16 位十六进制),按前缀比
394
+ if (entry.sha256 && /^[0-9a-f]+$/i.test(entry.sha256)) {
395
+ const actual = sha256Hex(buf);
396
+ if (!actual.toLowerCase().startsWith(entry.sha256.toLowerCase())) {
397
+ throw new Error('校验失败:sha256 不匹配(下载可能被截断)');
398
+ }
399
+ }
400
+
401
+ mkdirSync(skinsDir, { recursive: true });
402
+ rmSync(tmpDir, { recursive: true, force: true });
403
+ mkdirSync(tmpDir, { recursive: true });
404
+ const { files } = extractZipTo(buf, destDir, tmpDir);
405
+
406
+ // zip 顶层就是 `<id>/`:把它里面的内容搬到 destDir
407
+ const inner = join(tmpDir, id);
408
+ const innerExists = existsSync(inner) && statSync(inner).isDirectory();
409
+ const payload = innerExists ? inner : tmpDir;
410
+ rmSync(destDir, { recursive: true, force: true });
411
+ mkdirSync(destDir, { recursive: true });
412
+ for (const name of readdirSync(payload)) {
413
+ const from = join(payload, name);
414
+ const to = join(destDir, name);
415
+ // 同一分区:rename 最快;跨设备时退化为复制
416
+ try {
417
+ renameSyncSafe(from, to);
418
+ } catch {
419
+ cpSyncSafe(from, to);
420
+ }
421
+ }
422
+ rmSync(tmpDir, { recursive: true, force: true });
423
+
424
+ if (!existsSync(join(destDir, 'skin.json'))) {
425
+ throw new Error('装完没有 skin.json —— zip 结构不对');
426
+ }
427
+ note('[skins] installed ' + id + ' (' + files + ' files)');
428
+ sendJson(res, 200, { ok: true, id, files, bytes: buf.length, installed: true });
429
+ } catch (err) {
430
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 清理失败不影响应答 */ }
431
+ const msg = String((err && err.message) || err);
432
+ note('[skins] install ' + id + ' failed: ' + msg);
433
+ sendJson(res, 502, { ok: false, error: 'install-failed', detail: msg });
434
+ }
435
+ },
436
+ }));
437
+
438
+ disposers.push(webServer.register({
439
+ kind: 'exact',
440
+ path: `${base}/skins/activate`,
441
+ handler: async (req, res) => {
442
+ const id = await readIdBody(req);
443
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
444
+ const harness = harnessRoot();
445
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
446
+ if (!existsSync(join(harness, SKINS_DIR_NAME, id, 'skin.json'))) {
447
+ sendJson(res, 404, { ok: false, error: 'not-installed' });
448
+ return;
449
+ }
450
+ const file = join(harness, ACTIVE_FILE);
451
+ let doc = {};
452
+ try {
453
+ doc = JSON.parse(readFileSync(file, 'utf8')) || {};
454
+ } catch { /* 文件不存在 / 坏掉 ⇒ 用最小骨架重建 */ }
455
+ // 只改 `active`:background 那一段是皮肤中心自己的设置,一个字都不能丢。
456
+ doc.active = id;
457
+ if (typeof doc.initialized !== 'boolean') doc.initialized = true;
458
+ try {
459
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
460
+ note('[skins] activated ' + id);
461
+ sendJson(res, 200, { ok: true, id, active: id, restartRequired: true });
462
+ } catch (err) {
463
+ sendJson(res, 500, { ok: false, error: 'write-failed', detail: String(err && err.message || err) });
464
+ }
465
+ },
466
+ }));
467
+
468
+ disposers.push(webServer.register({
469
+ kind: 'exact',
470
+ path: `${base}/skins/remove`,
471
+ handler: async (req, res) => {
472
+ const id = await readIdBody(req);
473
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
474
+ const harness = harnessRoot();
475
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
476
+ if (activeSkinId(harness) === id) {
477
+ sendJson(res, 409, { ok: false, error: 'skin-active' });
478
+ return;
479
+ }
480
+ const dir = join(harness, SKINS_DIR_NAME, id);
481
+ if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
482
+ try {
483
+ rmSync(dir, { recursive: true, force: true });
484
+ note('[skins] removed ' + id);
485
+ sendJson(res, 200, { ok: true, id, removed: true });
486
+ } catch (err) {
487
+ sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(err && err.message || err) });
488
+ }
489
+ },
490
+ }));
491
+
492
+ disposers.push(webServer.register({
493
+ kind: 'exact',
494
+ path: `${base}/skins/preview`,
495
+ handler: async (req, res) => {
496
+ // 预览图代理:浏览器不能直连站点(跨境),也避免把站点 URL 散到客户端各处。
497
+ const url = new URL(req.url || '/', 'http://x');
498
+ const rel = url.searchParams.get('p') || '';
499
+ const site = skinsSiteBase();
500
+ if (!site) { res.statusCode = 503; res.end(); return; }
501
+ const segs = safeRelPath(rel);
502
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
503
+ try {
504
+ const r = await fetch(site + '/' + segs.join('/'), {
505
+ headers: { accept: 'image/*' },
506
+ signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
507
+ });
508
+ if (!r.ok) { res.statusCode = 404; res.end(); return; }
509
+ const buf = Buffer.from(await r.arrayBuffer());
510
+ res.setHeader('Content-Type', r.headers.get('content-type') || 'image/jpeg');
511
+ res.setHeader('Cache-Control', 'public, max-age=3600');
512
+ res.end(buf);
513
+ } catch {
514
+ res.statusCode = 502;
515
+ res.end();
516
+ }
517
+ },
518
+ }));
519
+ }
520
+
521
+ /* renameSync / cpSync 的薄封装:统一「同分区 rename、跨设备退化复制」这一处。 */
522
+ function renameSyncSafe(from, to) { renameSync(from, to); }
523
+ function cpSyncSafe(from, to) { cpSync(from, to, { recursive: true }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {