dsh-folia 4.9.1 → 4.10.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 +204 -0
- package/lib/features.js +4 -1
- package/lib/route-registry.js +9 -0
- package/lib/routes/skins.js +368 -0
- package/package.json +233 -233
package/lib/client.js
CHANGED
|
@@ -780,6 +780,27 @@ window.__ModuleLoader__.load({
|
|
|
780
780
|
"…": "…",
|
|
781
781
|
// 开机动画:已开启但无选片时的提示(4.8.2)
|
|
782
782
|
"已开启,但还没有选片 —— 下载一条后点「使用」": "Enabled, but no clip is selected yet — download one, then press Use",
|
|
783
|
+
|
|
784
|
+
// ── 助手皮肤页签(4.10.0)────────────────────────────────────────────────
|
|
785
|
+
"助手皮肤": "Assistant skins",
|
|
786
|
+
"🎨 助手皮肤": "🎨 Assistant skins",
|
|
787
|
+
"皮肤包安装到皮肤中心目录;装完到「设置 → 皮肤中心」里点选即可启用。皮肤中心与本插件读同一个目录,无需重启。": "Skin packs are installed into the skin centre's directory; pick one in Settings → Skin centre to enable it. It reads the same folder, so no restart is needed.",
|
|
788
|
+
"本机已装({n})": "Installed ({n})",
|
|
789
|
+
"站点皮肤({n})": "Skins on the site ({n})",
|
|
790
|
+
"还没有安装任何皮肤 —— 从下面的站点清单里挑一个。": "No skins installed yet — pick one from the site list below.",
|
|
791
|
+
"站点上还没有皮肤。": "No skins on the site yet.",
|
|
792
|
+
"含动效钩子": "animated hooks",
|
|
793
|
+
"已安装": "Installed",
|
|
794
|
+
"已安装过:{name}": "Already installed: {name}",
|
|
795
|
+
"已安装:{name} —— 到「设置 → 皮肤中心」点它即可启用": "Installed: {name} — pick it in Settings → Skin centre to enable",
|
|
796
|
+
"卸载中…": "Uninstalling…",
|
|
797
|
+
"动态": "Animated",
|
|
798
|
+
"这个包里没有 skin.json,不是皮肤包": "No skin.json in this archive — not a skin pack",
|
|
799
|
+
"包里有非法路径(已整包拒绝)": "The archive contains an unsafe path (rejected entirely)",
|
|
800
|
+
"包太大了(上限 200 MB)": "Archive is too large (200 MB limit)",
|
|
801
|
+
"下载地址不在白名单里": "The download host is not allow-listed",
|
|
802
|
+
"站点被关闭了(DSH_FOLIA_SITE=off)": "The site is disabled (DSH_FOLIA_SITE=off)",
|
|
803
|
+
"这个文件不是 zip": "This file is not a zip",
|
|
783
804
|
};
|
|
784
805
|
|
|
785
806
|
// ── 内联模块:src/i18n-copy-picker.js —— 英文词表域:壁纸选择器与快捷面板(picker-* · quick-panel · sidebar-right · theme-follow)(构建期注入;勿手改本段)──
|
|
@@ -18213,6 +18234,186 @@ window.__ModuleLoader__.load({
|
|
|
18213
18234
|
|
|
18214
18235
|
// 仅供单测驱动(构建期内联时会剥掉 export,不影响产物)。
|
|
18215
18236
|
|
|
18237
|
+
// ── 内联模块:src/panel-tabs-skins.js —— 「助手皮肤」页签:站点皮肤清单/安装/卸载 — 渲染由皮肤中心接管,我们只把 zip 解到 $DSH_HOME/skins(构建期注入;勿手改本段)──
|
|
18238
|
+
/**
|
|
18239
|
+
* panel-tabs-skins.js — 「助手皮肤」页签:站点皮肤清单 / 安装 / 卸载 / 去皮肤中心启用。
|
|
18240
|
+
*
|
|
18241
|
+
* 与「桌宠」页签同构(fetch → busy → note → 卡片列表)。差别在**安装之后谁负责渲染**:
|
|
18242
|
+
* 桌宠由本插件自己画(pet-layer.js),皮肤则由皮肤中心接管 —— 我们只把 zip 解到
|
|
18243
|
+
* `$DSH_HOME/skins/<id>/`,皮肤中心下一次 catalog 扫描就会认领(它的缓存键里带父目录 mtime)。
|
|
18244
|
+
* 所以这个页签的职责边界是:**装/卸 + 告诉你下一步去哪启用**,不重复实现渲染。
|
|
18245
|
+
*/
|
|
18246
|
+
|
|
18247
|
+
const SKIN_STYLE = {
|
|
18248
|
+
row: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
|
|
18249
|
+
card: {
|
|
18250
|
+
display: "flex", flexDirection: "column", gap: "6px", padding: "10px",
|
|
18251
|
+
borderRadius: "10px", border: "1px solid rgba(255,255,255,0.14)",
|
|
18252
|
+
background: "rgba(255,255,255,0.04)", width: "188px",
|
|
18253
|
+
},
|
|
18254
|
+
thumb: {
|
|
18255
|
+
width: "100%", aspectRatio: "16 / 9", borderRadius: "6px",
|
|
18256
|
+
backgroundSize: "cover", backgroundPosition: "center",
|
|
18257
|
+
backgroundColor: "rgba(0,0,0,0.25)",
|
|
18258
|
+
},
|
|
18259
|
+
title: { fontSize: "13px", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
18260
|
+
meta: { fontSize: "11px", opacity: 0.72 },
|
|
18261
|
+
grid: { display: "flex", gap: "10px", flexWrap: "wrap", marginTop: "10px" },
|
|
18262
|
+
badge: {
|
|
18263
|
+
fontSize: "10px", padding: "1px 6px", borderRadius: "4px",
|
|
18264
|
+
background: "rgba(30,90,160,0.55)", alignSelf: "flex-start",
|
|
18265
|
+
},
|
|
18266
|
+
};
|
|
18267
|
+
|
|
18268
|
+
function skinFmtBytes(n) {
|
|
18269
|
+
const v = Number(n) || 0;
|
|
18270
|
+
if (!v) return "";
|
|
18271
|
+
if (v < 1024 * 1024) return Math.round(v / 1024) + " KB";
|
|
18272
|
+
return (v / 1048576).toFixed(1) + " MB";
|
|
18273
|
+
}
|
|
18274
|
+
|
|
18275
|
+
function SkinsTabPanel() {
|
|
18276
|
+
const [state, setState] = React.useState(null);
|
|
18277
|
+
const [busy, setBusy] = React.useState("");
|
|
18278
|
+
const [note, setNote] = React.useState(null);
|
|
18279
|
+
|
|
18280
|
+
const load = React.useCallback(async (force) => {
|
|
18281
|
+
try {
|
|
18282
|
+
const resp = await apiJson("/skins/list" + (force ? "?refresh=1" : ""), { parse: "always" });
|
|
18283
|
+
const doc = (resp && resp.data) || null;
|
|
18284
|
+
if (resp && resp.ok && doc && doc.ok) {
|
|
18285
|
+
setState(doc);
|
|
18286
|
+
if (doc.siteError && doc.siteError !== "site-off") {
|
|
18287
|
+
setNote({ ok: false, text: weT("站点清单读不到:{msg}", { msg: String(doc.siteError) }) });
|
|
18288
|
+
}
|
|
18289
|
+
} else {
|
|
18290
|
+
setNote({ ok: false, text: weT("读取失败:{msg}", { msg: (doc && doc.error) || "unknown" }) });
|
|
18291
|
+
}
|
|
18292
|
+
} catch (e) {
|
|
18293
|
+
setNote({ ok: false, text: weT("请求失败:{msg}", { msg: (e && e.message) || weT("网络错误") }) });
|
|
18294
|
+
}
|
|
18295
|
+
}, []);
|
|
18296
|
+
|
|
18297
|
+
React.useEffect(() => { load(false); }, [load]);
|
|
18298
|
+
|
|
18299
|
+
const act = async (kind, payload, label) => {
|
|
18300
|
+
const structural = kind === "install" || kind === "remove";
|
|
18301
|
+
if (structural) setBusy(kind + ":" + ((payload && payload.id) || ""));
|
|
18302
|
+
setNote(null);
|
|
18303
|
+
try {
|
|
18304
|
+
const resp = await apiPostJson("/skins/" + kind, payload || {}, { parse: "always" });
|
|
18305
|
+
const r = (resp && resp.data) || null;
|
|
18306
|
+
if (resp && resp.ok && r && r.ok) {
|
|
18307
|
+
if (kind === "install") {
|
|
18308
|
+
setNote({
|
|
18309
|
+
ok: true,
|
|
18310
|
+
text: r.exists
|
|
18311
|
+
? weT("已安装过:{name}", { name: label || "" })
|
|
18312
|
+
: weT("已安装:{name} —— 到「设置 → 皮肤中心」点它即可启用", { name: label || "" }),
|
|
18313
|
+
});
|
|
18314
|
+
}
|
|
18315
|
+
if (kind === "remove") setNote({ ok: true, text: weT("已卸载:{name}", { name: label || "" }) });
|
|
18316
|
+
await load(true);
|
|
18317
|
+
} else {
|
|
18318
|
+
const code = (r && r.error) || "failed";
|
|
18319
|
+
const map = {
|
|
18320
|
+
'no-skin-json': weT("这个包里没有 skin.json,不是皮肤包"),
|
|
18321
|
+
'unsafe-entry': weT("包里有非法路径(已整包拒绝)"),
|
|
18322
|
+
'too-large': weT("包太大了(上限 200 MB)"),
|
|
18323
|
+
'host-not-allowed': weT("下载地址不在白名单里"),
|
|
18324
|
+
'site-off': weT("站点被关闭了(DSH_FOLIA_SITE=off)"),
|
|
18325
|
+
'not-a-zip': weT("这个文件不是 zip"),
|
|
18326
|
+
};
|
|
18327
|
+
setNote({ ok: false, text: map[code] || weT("操作失败:{code}", { code }) });
|
|
18328
|
+
}
|
|
18329
|
+
} catch (e) {
|
|
18330
|
+
setNote({ ok: false, text: weT("请求失败:{msg}", { msg: (e && e.message) || weT("网络错误") }) });
|
|
18331
|
+
} finally {
|
|
18332
|
+
setBusy("");
|
|
18333
|
+
}
|
|
18334
|
+
};
|
|
18335
|
+
|
|
18336
|
+
const children = [];
|
|
18337
|
+
children.push(React.createElement("div", { key: "head", style: SKIN_STYLE.row },
|
|
18338
|
+
React.createElement("div", { style: { fontSize: "13px", fontWeight: 600 } }, weT("🎨 助手皮肤")),
|
|
18339
|
+
React.createElement("button", {
|
|
18340
|
+
className: "we-picker__btn", type: "button",
|
|
18341
|
+
disabled: Boolean(busy),
|
|
18342
|
+
onClick: () => load(true),
|
|
18343
|
+
}, weT("刷新")),
|
|
18344
|
+
));
|
|
18345
|
+
|
|
18346
|
+
children.push(React.createElement("div", {
|
|
18347
|
+
key: "hint",
|
|
18348
|
+
className: "we-picker__hint",
|
|
18349
|
+
style: { margin: "6px 0 4px", opacity: 0.8 },
|
|
18350
|
+
}, weT("皮肤包安装到皮肤中心目录;装完到「设置 → 皮肤中心」里点选即可启用。皮肤中心与本插件读同一个目录,无需重启。")));
|
|
18351
|
+
|
|
18352
|
+
// 提示行常驻占位(与开机动画/桌宠页签同一条纪律:不因 note 出现/消失让面板跳高度)
|
|
18353
|
+
children.push(React.createElement("div", {
|
|
18354
|
+
key: "note",
|
|
18355
|
+
className: "we-picker__hint",
|
|
18356
|
+
style: {
|
|
18357
|
+
color: note && !note.ok ? "#ff9a9a" : "#8ee6a8",
|
|
18358
|
+
margin: "2px 0 8px", minHeight: "18px", lineHeight: "18px",
|
|
18359
|
+
opacity: note ? 1 : 0, transition: "opacity 120ms linear",
|
|
18360
|
+
},
|
|
18361
|
+
}, note ? note.text : "\u00a0"));
|
|
18362
|
+
|
|
18363
|
+
const installed = (state && state.installed) || [];
|
|
18364
|
+
if (state) {
|
|
18365
|
+
children.push(React.createElement("div", { key: "installed-head", className: "we-picker__hint" },
|
|
18366
|
+
weT("本机已装({n})", { n: installed.length })));
|
|
18367
|
+
children.push(React.createElement("div", { key: "installed", style: SKIN_STYLE.grid },
|
|
18368
|
+
installed.length === 0
|
|
18369
|
+
? [React.createElement("span", { key: "none", className: "we-picker__hint" }, weT("还没有安装任何皮肤 —— 从下面的站点清单里挑一个。"))]
|
|
18370
|
+
: installed.map((s) => React.createElement("div", { key: s.id, style: SKIN_STYLE.card },
|
|
18371
|
+
React.createElement("div", { style: { ...SKIN_STYLE.title, color: s.accent || undefined } }, s.name || s.id),
|
|
18372
|
+
React.createElement("div", { style: SKIN_STYLE.meta },
|
|
18373
|
+
[s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : ""]
|
|
18374
|
+
.filter(Boolean).join(" · ")),
|
|
18375
|
+
React.createElement("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
18376
|
+
React.createElement("button", {
|
|
18377
|
+
className: "we-picker__btn", type: "button",
|
|
18378
|
+
disabled: Boolean(busy),
|
|
18379
|
+
onClick: () => act("remove", { id: s.id }, s.name),
|
|
18380
|
+
}, busy === "remove:" + s.id ? weT("卸载中…") : weT("卸载")),
|
|
18381
|
+
),
|
|
18382
|
+
)),
|
|
18383
|
+
));
|
|
18384
|
+
}
|
|
18385
|
+
|
|
18386
|
+
const site = (state && state.site) || [];
|
|
18387
|
+
children.push(React.createElement("div", { key: "site-head", className: "we-picker__hint", style: { marginTop: "10px" } },
|
|
18388
|
+
weT("站点皮肤({n})", { n: site.length })));
|
|
18389
|
+
children.push(React.createElement("div", { key: "site", style: SKIN_STYLE.grid },
|
|
18390
|
+
site.length === 0
|
|
18391
|
+
? [React.createElement("span", { key: "none", className: "we-picker__hint" }, weT("站点上还没有皮肤。"))]
|
|
18392
|
+
: site.map((s) => {
|
|
18393
|
+
const busyThis = busy === "install:" + s.id;
|
|
18394
|
+
return React.createElement("div", { key: s.id, style: SKIN_STYLE.card },
|
|
18395
|
+
s.thumbUrl
|
|
18396
|
+
? React.createElement("div", {
|
|
18397
|
+
style: { ...SKIN_STYLE.thumb, backgroundImage: "url(" + JSON.stringify(s.thumbUrl) + ")" },
|
|
18398
|
+
})
|
|
18399
|
+
: React.createElement("div", { style: SKIN_STYLE.thumb }),
|
|
18400
|
+
React.createElement("div", { style: SKIN_STYLE.title, title: s.title }, s.title),
|
|
18401
|
+
React.createElement("div", { style: SKIN_STYLE.meta },
|
|
18402
|
+
[skinFmtBytes(s.bytes), (s.tags || []).includes("动态") ? weT("动态") : ""].filter(Boolean).join(" · ")),
|
|
18403
|
+
s.installed
|
|
18404
|
+
? React.createElement("div", { style: SKIN_STYLE.badge }, weT("已安装"))
|
|
18405
|
+
: React.createElement("button", {
|
|
18406
|
+
className: "we-picker__btn", type: "button",
|
|
18407
|
+
disabled: Boolean(busy),
|
|
18408
|
+
onClick: () => act("install", { id: s.id, url: s.url }, s.title),
|
|
18409
|
+
}, busyThis ? weT("安装中…") : weT("安装")),
|
|
18410
|
+
);
|
|
18411
|
+
}),
|
|
18412
|
+
));
|
|
18413
|
+
|
|
18414
|
+
return React.createElement("div", { className: "we-picker", style: { display: "flex", flexDirection: "column" } }, children);
|
|
18415
|
+
}
|
|
18416
|
+
|
|
18216
18417
|
// ── 内联模块:src/features.js —— 客户端功能注册表:页签条 / 整区面板 / 常驻挂载层。装配点(quick-panel、client.js)只查表,不写功能名(构建期注入;勿手改本段)──
|
|
18217
18418
|
/**
|
|
18218
18419
|
* features.js(客户端)— **功能注册表**:页签声明、面板工厂、挂载钩子。
|
|
@@ -18241,6 +18442,8 @@ window.__ModuleLoader__.load({
|
|
|
18241
18442
|
{ id: "playback", get label() { return weT("播放"); } },
|
|
18242
18443
|
// 桌宠是"桌面上多出来的一只东西",与壁纸/开机动画并列。
|
|
18243
18444
|
{ id: "pets", get label() { return weT("桌宠"); } },
|
|
18445
|
+
// 助手皮肤:装的是皮肤中心的皮肤包。我们只管装/卸,渲染与启用归皮肤中心。
|
|
18446
|
+
{ id: "skins", get label() { return weT("助手皮肤"); } },
|
|
18244
18447
|
];
|
|
18245
18448
|
|
|
18246
18449
|
/**
|
|
@@ -18250,6 +18453,7 @@ window.__ModuleLoader__.load({
|
|
|
18250
18453
|
const WE_FEATURE_PANELS = {
|
|
18251
18454
|
boot: BootTabPanel,
|
|
18252
18455
|
pets: PetsTabPanel,
|
|
18456
|
+
skins: SkinsTabPanel,
|
|
18253
18457
|
};
|
|
18254
18458
|
|
|
18255
18459
|
/**
|
package/lib/features.js
CHANGED
|
@@ -126,12 +126,15 @@ export const FEATURES = [
|
|
|
126
126
|
host: [
|
|
127
127
|
'lib/routes/settings.js',
|
|
128
128
|
'lib/routes/presets.js',
|
|
129
|
+
// 助手皮肤:站点皮肤包 → $DSH_HOME/skins(皮肤中心读同一目录,写盘即生效)。
|
|
130
|
+
'lib/routes/skins.js',
|
|
129
131
|
'lib/settings-schema.js',
|
|
130
132
|
'lib/components/fonts/routes/fontsets.js',
|
|
131
133
|
'lib/components/fonts/routes/system-fonts.js',
|
|
132
134
|
],
|
|
133
135
|
client: [
|
|
134
136
|
'src/panel-tabs-appearance.js',
|
|
137
|
+
'src/panel-tabs-skins.js',
|
|
135
138
|
'src/glass.js',
|
|
136
139
|
'src/glass-panel.js',
|
|
137
140
|
'src/glass-surfaces.js',
|
|
@@ -151,7 +154,7 @@ export const FEATURES = [
|
|
|
151
154
|
'src/about-assets.js',
|
|
152
155
|
'src/nav-icon.js',
|
|
153
156
|
],
|
|
154
|
-
tab: ['appearance'],
|
|
157
|
+
tab: ['appearance', 'skins'],
|
|
155
158
|
},
|
|
156
159
|
{
|
|
157
160
|
id: 'playback',
|
package/lib/route-registry.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerInventoryRoutes, warmInventorySiteCache } from './routes/invent
|
|
|
19
19
|
import { registerModulesRoutes, warmModulesCache } from './routes/modules.js';
|
|
20
20
|
import { registerBootRoutes } from './routes/boot.js';
|
|
21
21
|
import { registerSiteThumbRoutes } from './routes/site-thumb.js';
|
|
22
|
+
import { registerSkinsRoutes } from './routes/skins.js';
|
|
22
23
|
import { registerPetsRoutes } from './routes/pets.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -71,6 +72,14 @@ export const FEATURE_ROUTE_FAMILIES = [
|
|
|
71
72
|
register: registerPetsRoutes,
|
|
72
73
|
deps: (c) => ({ disposers: c.disposers, base: c.base, log: c.log }),
|
|
73
74
|
},
|
|
75
|
+
{
|
|
76
|
+
id: 'skins',
|
|
77
|
+
file: 'lib/routes/skins.js',
|
|
78
|
+
register: registerSkinsRoutes,
|
|
79
|
+
// 皮肤安装目标是 $DSH_HOME/skins(皮肤中心读同一个目录):由路由自己按 DSH_HOME 解析,
|
|
80
|
+
// 不额外引入注入面 —— 少一处注入,就少一种"改了目录却没生效"的可能。
|
|
81
|
+
deps: (c) => ({ disposers: c.disposers, base: c.base, log: c.log }),
|
|
82
|
+
},
|
|
74
83
|
];
|
|
75
84
|
|
|
76
85
|
/**
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes/skins.js — **助手皮肤**页签的宿主半边:列站点皮肤、安装到皮肤中心目录、卸载。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是这个形态(而不是另造一套渲染):皮肤中心扫的是 `$DSH_HOME/skins/<id>/`
|
|
5
|
+
* (`skin.json` + `skin.css` + 可选 `hooks.mjs` + `assets/`)。我们只要**把站点上的 zip 解到那个目录**,
|
|
6
|
+
* 皮肤中心下一次 catalog 扫描就会认领它 —— 它的缓存键里带父目录 `mtimeMs`,新增目录必然穿透缓存。
|
|
7
|
+
* 所以这里不"通知"皮肤中心,也不需要它配合:写盘即生效。
|
|
8
|
+
*
|
|
9
|
+
* 数据契约(站点一条 = 一个 zip;解出来必须是 v2 皮肤目录):
|
|
10
|
+
* <皮肤根>/<id>/skin.json 必填(id/name 从里面取,**不信任 URL 里的名字**)
|
|
11
|
+
* <皮肤根>/<id>/skin.css 必填(contributes.stylesheet 指向它)
|
|
12
|
+
* hooks.mjs 可选;带它时皮肤中心会要求 `dsh-market.provenance.json` 字节对得上才执行 ——
|
|
13
|
+
* 我们的 zip 是原样打的,所以 provenance 一起解出来,语义与市场安装一致。
|
|
14
|
+
*
|
|
15
|
+
* 纪律:
|
|
16
|
+
* · 只写 `$DSH_HOME/skins`,删也只删它下面(realpath 前缀校验,防符号链接逃逸);
|
|
17
|
+
* · 下载白名单 = 站点 host(挡 SSRF);只认 zip 魔数;单包上限 200 MB;
|
|
18
|
+
* · 解包拒路径穿越(整包拒绝,不是跳过);可执行后缀一律不落盘;
|
|
19
|
+
* · 失败清干净:绝不留下没有 skin.json 的残缺目录(否则皮肤中心会把它算进"坏皮肤")。
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { inflateRawSync } from 'node:zlib';
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** 注入契约(ADR-0013 D12)。 */
|
|
28
|
+
export { unzipSkinZip, safeSkinId };
|
|
29
|
+
export const DEPS = ['disposers', 'base', 'log'];
|
|
30
|
+
|
|
31
|
+
/** 站点分类 slug:助手皮肤。 */
|
|
32
|
+
const SKIN_CATEGORY = 'assistant-skin';
|
|
33
|
+
const MAX_ZIP_BYTES = 200 * 1024 * 1024;
|
|
34
|
+
const FETCH_TIMEOUT_MS = 60000;
|
|
35
|
+
const SKIN_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
36
|
+
/** 可执行/脚本后缀:皮肤包不需要,一律不写(防"皮肤包里塞个 exe")。 */
|
|
37
|
+
const SKIP_EXEC_RE = /\.(exe|dll|bat|cmd|com|scr|ps1|sh|msi|vbs|wsf|hta|lnk|jar)$/i;
|
|
38
|
+
|
|
39
|
+
function dshHome() {
|
|
40
|
+
return process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
|
|
41
|
+
}
|
|
42
|
+
/** 皮肤根:与皮肤中心同一个目录(它读 `$DSH_HOME/skins`)。 */
|
|
43
|
+
function skinsRoot() { return join(dshHome(), 'skins'); }
|
|
44
|
+
|
|
45
|
+
function siteBase() {
|
|
46
|
+
const raw = process.env.DSH_FOLIA_SITE;
|
|
47
|
+
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
|
|
48
|
+
const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
|
|
49
|
+
return base.replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 站点列表缓存(30 秒):面板打开与"刷新"共用,避免每次都打站点。 */
|
|
53
|
+
const siteCache = { at: 0, items: [], error: null };
|
|
54
|
+
const SITE_TTL_MS = 30000;
|
|
55
|
+
|
|
56
|
+
async function fetchSiteSkins(force) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (!force && siteCache.at && now - siteCache.at < SITE_TTL_MS) return siteCache;
|
|
59
|
+
const base = siteBase();
|
|
60
|
+
if (!base) { siteCache.at = now; siteCache.items = []; siteCache.error = 'site-off'; return siteCache; }
|
|
61
|
+
try {
|
|
62
|
+
const url = base + '/api/public/wallpapers?category=' + encodeURIComponent(SKIN_CATEGORY) + '&limit=100';
|
|
63
|
+
const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20000) });
|
|
64
|
+
if (!res.ok) throw new Error('http-' + res.status);
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
const items = (body && body.data && body.data.items) || [];
|
|
67
|
+
siteCache.items = items.map((it) => ({
|
|
68
|
+
id: String(it.slug || ''),
|
|
69
|
+
title: String(it.title || it.slug || ''),
|
|
70
|
+
bytes: Number(it.bytes) || 0,
|
|
71
|
+
mime: String(it.mime || ''),
|
|
72
|
+
url: String(it.url || ''),
|
|
73
|
+
thumbUrl: it.thumbUrl ? String(it.thumbUrl) : '',
|
|
74
|
+
tags: (() => { try { return JSON.parse(it.tags || '[]'); } catch { return []; } })(),
|
|
75
|
+
}));
|
|
76
|
+
siteCache.error = null;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
siteCache.error = (e && e.message) || 'fetch-failed';
|
|
79
|
+
siteCache.items = siteCache.items || [];
|
|
80
|
+
}
|
|
81
|
+
siteCache.at = now;
|
|
82
|
+
return siteCache;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 本机已装皮肤(只读 `$DSH_HOME/skins`)。 */
|
|
86
|
+
function listInstalled() {
|
|
87
|
+
const root = skinsRoot();
|
|
88
|
+
let names = [];
|
|
89
|
+
try { names = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return []; }
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const dirId of names) {
|
|
92
|
+
const dir = join(root, dirId);
|
|
93
|
+
let manifest;
|
|
94
|
+
try { manifest = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8')); } catch { continue; }
|
|
95
|
+
let bytes = 0;
|
|
96
|
+
try { bytes = dirBytes(dir); } catch { /* 忽略 */ }
|
|
97
|
+
out.push({
|
|
98
|
+
dirId,
|
|
99
|
+
id: String(manifest.id || dirId),
|
|
100
|
+
name: String(manifest.name || manifest.nameEn || manifest.id || dirId),
|
|
101
|
+
nameEn: manifest.nameEn ? String(manifest.nameEn) : '',
|
|
102
|
+
version: String(manifest.version || ''),
|
|
103
|
+
author: String(manifest.author || ''),
|
|
104
|
+
accent: manifest.accent ? String(manifest.accent) : '',
|
|
105
|
+
hasHooks: existsSync(join(dir, 'hooks.mjs')),
|
|
106
|
+
hasProvenance: existsSync(join(dir, 'dsh-market.provenance.json')),
|
|
107
|
+
bytes,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
out.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dirBytes(dir) {
|
|
115
|
+
let total = 0;
|
|
116
|
+
for (const name of readdirSync(dir)) {
|
|
117
|
+
const abs = join(dir, name);
|
|
118
|
+
const st = statSync(abs);
|
|
119
|
+
total += st.isDirectory() ? dirBytes(abs) : st.size;
|
|
120
|
+
}
|
|
121
|
+
return total;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── zip 读取(只读,够用即可;判据见 test/skins-http.mjs)────────────────── */
|
|
125
|
+
/**
|
|
126
|
+
* 解一个 zip 缓冲成条目数组。
|
|
127
|
+
*
|
|
128
|
+
* @param {Buffer} buf
|
|
129
|
+
* @param {{ maxEntries?: number, maxEntryBytes?: number, maxTotalBytes?: number }} [limits]
|
|
130
|
+
* @returns {{ name: string, data: Buffer, isDir: boolean }[]}
|
|
131
|
+
* @throws {Error} code: `not-a-zip` / `bad-zip` / `unsafe-entry` / `too-large`
|
|
132
|
+
*/
|
|
133
|
+
function unzipSkinZip(buf, limits = {}) {
|
|
134
|
+
const maxEntries = limits.maxEntries || 4000;
|
|
135
|
+
const maxEntry = limits.maxEntryBytes || 256 * 1024 * 1024;
|
|
136
|
+
const maxTotal = limits.maxTotalBytes || 512 * 1024 * 1024;
|
|
137
|
+
if (!Buffer.isBuffer(buf) || buf.length < 22) throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
|
|
138
|
+
const magic = buf.readUInt32LE(0);
|
|
139
|
+
if (magic !== 0x04034b50 && magic !== 0x06054b50) throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
|
|
140
|
+
let eocd = -1;
|
|
141
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i -= 1) {
|
|
142
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
143
|
+
}
|
|
144
|
+
if (eocd < 0) throw Object.assign(new Error('no EOCD'), { code: 'bad-zip' });
|
|
145
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
146
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
147
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
148
|
+
if (count > maxEntries) throw Object.assign(new Error('too many entries'), { code: 'too-large' });
|
|
149
|
+
if (cdOffset + cdSize > buf.length) throw Object.assign(new Error('CD out of range'), { code: 'bad-zip' });
|
|
150
|
+
const out = [];
|
|
151
|
+
let p = cdOffset;
|
|
152
|
+
let total = 0;
|
|
153
|
+
for (let i = 0; i < count; i += 1) {
|
|
154
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) throw Object.assign(new Error('bad CD'), { code: 'bad-zip' });
|
|
155
|
+
const method = buf.readUInt16LE(p + 10);
|
|
156
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
157
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
158
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
159
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
160
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
161
|
+
const localOffset = buf.readUInt32LE(p + 42);
|
|
162
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
163
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
164
|
+
const norm = String(name || '').replace(/\\/g, '/');
|
|
165
|
+
if (!norm || norm.includes('\0') || norm.startsWith('/') || /^[a-zA-Z]:/.test(norm) || norm.split('/').some((s) => s === '..')) {
|
|
166
|
+
throw Object.assign(new Error('unsafe entry: ' + name), { code: 'unsafe-entry' });
|
|
167
|
+
}
|
|
168
|
+
if (norm.endsWith('/')) { out.push({ name: norm, data: Buffer.alloc(0), isDir: true }); continue; }
|
|
169
|
+
if (uncompSize > maxEntry) throw Object.assign(new Error('entry too large'), { code: 'too-large' });
|
|
170
|
+
total += uncompSize;
|
|
171
|
+
if (total > maxTotal) throw Object.assign(new Error('archive too large'), { code: 'too-large' });
|
|
172
|
+
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) throw Object.assign(new Error('bad local header'), { code: 'bad-zip' });
|
|
173
|
+
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
|
174
|
+
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
|
175
|
+
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
|
176
|
+
const dataEnd = dataStart + compSize;
|
|
177
|
+
if (dataEnd > buf.length) throw Object.assign(new Error('truncated'), { code: 'bad-zip' });
|
|
178
|
+
const raw = buf.subarray(dataStart, dataEnd);
|
|
179
|
+
let data;
|
|
180
|
+
if (method === 0) data = Buffer.from(raw);
|
|
181
|
+
else if (method === 8) {
|
|
182
|
+
try { data = inflateRawSync(raw); } catch (e) { throw Object.assign(new Error('inflate failed'), { code: 'bad-zip' }); }
|
|
183
|
+
} else throw Object.assign(new Error('unsupported method ' + method), { code: 'bad-zip' });
|
|
184
|
+
out.push({ name: norm, data, isDir: false });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 比对本机/站点 id 用的归一键:去掉可选的 `skin-` 前缀(站点 slug 为避撞名会加)。 */
|
|
190
|
+
function normSkinId(raw) {
|
|
191
|
+
const s = String(raw || '').trim().toLowerCase();
|
|
192
|
+
return s.startsWith('skin-') ? s.slice(5) : s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 皮肤 id 只允许安全字符(要当目录名用)。 */
|
|
196
|
+
function safeSkinId(raw) {
|
|
197
|
+
const s = String(raw || '').trim();
|
|
198
|
+
return SKIN_ID_RE.test(s) ? s : '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 校验"删/写这个目录"确实在皮肤根下面(realpath 前缀,挡符号链接逃逸)。 */
|
|
202
|
+
function resolveInside(root, id) {
|
|
203
|
+
const dir = join(root, id);
|
|
204
|
+
const realRoot = existsSync(root) ? realpathSync(root) : root;
|
|
205
|
+
const realDir = existsSync(dir) ? realpathSync(dir) : dir;
|
|
206
|
+
const withSep = realRoot.endsWith('\\') || realRoot.endsWith('/') ? realRoot : realRoot + (process.platform === 'win32' ? '\\' : '/');
|
|
207
|
+
return realDir === realRoot || realDir.startsWith(withSep) ? dir : '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sendJson(res, code, payload) {
|
|
211
|
+
res.statusCode = code;
|
|
212
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
213
|
+
res.end(JSON.stringify(payload));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readJsonBody(req, limit = 64 * 1024) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let acc = '';
|
|
219
|
+
let done = false;
|
|
220
|
+
req.on('data', (d) => {
|
|
221
|
+
if (done) return;
|
|
222
|
+
acc += d;
|
|
223
|
+
if (acc.length > limit) { done = true; resolve(null); }
|
|
224
|
+
});
|
|
225
|
+
req.on('end', () => { if (!done) { done = true; try { resolve(JSON.parse(acc || '{}')); } catch { resolve(null); } } });
|
|
226
|
+
req.on('error', () => { if (!done) { done = true; resolve(null); } });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function registerSkinsRoutes(webServer, c) {
|
|
231
|
+
const { disposers, base, log } = c;
|
|
232
|
+
|
|
233
|
+
/* ① 列表:站点可装的 + 本机已装的(同一份响应,面板一次拿全) */
|
|
234
|
+
disposers.push(webServer.register({
|
|
235
|
+
kind: 'exact',
|
|
236
|
+
path: `${base}/skins/list`,
|
|
237
|
+
handler: async (req, res) => {
|
|
238
|
+
const force = /[?&]refresh=1/.test(req.url || '');
|
|
239
|
+
const site = await fetchSiteSkins(force);
|
|
240
|
+
const installed = listInstalled();
|
|
241
|
+
// ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
|
|
242
|
+
// 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
|
|
243
|
+
// 所以比对要归一:两边都去掉可选的 `skin-` 前缀再比,否则会显示成"未安装"。
|
|
244
|
+
const installedKeys = new Set();
|
|
245
|
+
for (const s of installed) {
|
|
246
|
+
installedKeys.add(normSkinId(s.id));
|
|
247
|
+
installedKeys.add(normSkinId(s.dirId));
|
|
248
|
+
}
|
|
249
|
+
sendJson(res, 200, {
|
|
250
|
+
ok: true,
|
|
251
|
+
siteBase: siteBase(),
|
|
252
|
+
siteError: site.error,
|
|
253
|
+
installed,
|
|
254
|
+
site: site.items.map((it) => ({ ...it, installed: installedKeys.has(normSkinId(it.id)) })),
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
/* ② 安装:下载 zip → 解到 $DSH_HOME/skins/<id> */
|
|
260
|
+
disposers.push(webServer.register({
|
|
261
|
+
kind: 'exact',
|
|
262
|
+
path: `${base}/skins/install`,
|
|
263
|
+
handler: async (req, res) => {
|
|
264
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
265
|
+
const body = await readJsonBody(req);
|
|
266
|
+
if (!body) { sendJson(res, 400, { ok: false, error: 'bad-body' }); return; }
|
|
267
|
+
const base0 = siteBase();
|
|
268
|
+
if (!base0) { sendJson(res, 400, { ok: false, error: 'site-off' }); return; }
|
|
269
|
+
let url;
|
|
270
|
+
try { url = new URL(String(body.url || ''), base0); } catch { sendJson(res, 400, { ok: false, error: 'bad-url' }); return; }
|
|
271
|
+
const allowHost = new URL(base0).host.toLowerCase();
|
|
272
|
+
if (url.host.toLowerCase() !== allowHost) { sendJson(res, 400, { ok: false, error: 'host-not-allowed' }); return; }
|
|
273
|
+
|
|
274
|
+
const root = skinsRoot();
|
|
275
|
+
// 幂等:站点 id 与本机目录同名时直接返回(用户"再点一次"应该是立刻生效,而不是重下一遍)
|
|
276
|
+
const hinted = safeSkinId(body.id);
|
|
277
|
+
if (hinted && existsSync(join(root, hinted, 'skin.json'))) {
|
|
278
|
+
sendJson(res, 200, { ok: true, exists: true, id: hinted, dir: join(root, hinted) });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let buf;
|
|
283
|
+
try {
|
|
284
|
+
const r = await fetch(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
285
|
+
if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
|
|
286
|
+
const len = Number(r.headers.get('content-length') || 0);
|
|
287
|
+
if (len && len > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
|
|
288
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
289
|
+
if (buf.length > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
|
|
290
|
+
} catch (e) {
|
|
291
|
+
sendJson(res, 502, { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : 'network' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let entries;
|
|
296
|
+
try { entries = unzipSkinZip(buf); } catch (e) {
|
|
297
|
+
sendJson(res, 422, { ok: false, error: (e && e.code) || 'bad-zip' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const manifestEntry = entries.find((e) => !e.isDir && /^skin\.json$/i.test(e.name));
|
|
301
|
+
if (!manifestEntry) { sendJson(res, 422, { ok: false, error: 'no-skin-json' }); return; }
|
|
302
|
+
let manifest;
|
|
303
|
+
try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } catch { sendJson(res, 422, { ok: false, error: 'bad-skin-json' }); return; }
|
|
304
|
+
// 目录名以 skin.json 的 id 为准(不信任 URL/调用方给的名字)。
|
|
305
|
+
const skinId = safeSkinId(manifest && manifest.id) || hinted;
|
|
306
|
+
if (!skinId) { sendJson(res, 422, { ok: false, error: 'bad-skin-id' }); return; }
|
|
307
|
+
const dir = resolveInside(root, skinId);
|
|
308
|
+
if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
|
|
309
|
+
if (existsSync(join(dir, 'skin.json'))) {
|
|
310
|
+
sendJson(res, 200, { ok: true, exists: true, id: skinId, dir });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
rmSync(dir, { recursive: true, force: true });
|
|
316
|
+
mkdirSync(dir, { recursive: true });
|
|
317
|
+
let written = 0;
|
|
318
|
+
let skipped = 0;
|
|
319
|
+
for (const e of entries) {
|
|
320
|
+
if (e.isDir) continue;
|
|
321
|
+
if (SKIP_EXEC_RE.test(e.name)) { skipped += 1; continue; }
|
|
322
|
+
const target = join(dir, e.name);
|
|
323
|
+
const parent = join(target, '..');
|
|
324
|
+
mkdirSync(parent, { recursive: true });
|
|
325
|
+
writeFileSync(target, e.data);
|
|
326
|
+
written += 1;
|
|
327
|
+
}
|
|
328
|
+
const check = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
|
|
329
|
+
if (!check || !check.id) throw new Error('manifest lost after write');
|
|
330
|
+
sendJson(res, 200, {
|
|
331
|
+
ok: true,
|
|
332
|
+
id: skinId,
|
|
333
|
+
name: String(check.name || check.nameEn || skinId),
|
|
334
|
+
dir,
|
|
335
|
+
written,
|
|
336
|
+
skipped,
|
|
337
|
+
bytes: buf.length,
|
|
338
|
+
installedDir: root,
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {
|
|
341
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
342
|
+
try { log && log('skins install failed: ' + (e && e.message)); } catch { /* ignore */ }
|
|
343
|
+
sendJson(res, 500, { ok: false, error: 'extract-failed', detail: String(e && e.message).slice(0, 200) });
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
/* ③ 卸载:只删皮肤根下的那个目录 */
|
|
349
|
+
disposers.push(webServer.register({
|
|
350
|
+
kind: 'exact',
|
|
351
|
+
path: `${base}/skins/remove`,
|
|
352
|
+
handler: async (req, res) => {
|
|
353
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
354
|
+
const body = await readJsonBody(req);
|
|
355
|
+
const id = safeSkinId(body && body.id);
|
|
356
|
+
if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
357
|
+
const root = skinsRoot();
|
|
358
|
+
const dir = resolveInside(root, id);
|
|
359
|
+
if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
|
|
360
|
+
if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
|
|
361
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch (e) {
|
|
362
|
+
sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(e && e.message).slice(0, 160) });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
sendJson(res, 200, { ok: true, id });
|
|
366
|
+
},
|
|
367
|
+
}));
|
|
368
|
+
}
|
package/package.json
CHANGED
|
@@ -1,234 +1,234 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-folia",
|
|
3
|
+
"version": "4.10.0",
|
|
4
|
+
"description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12.0",
|
|
8
|
+
"dsh": "^0.2.0-rc.1"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/types/index.d.ts",
|
|
12
|
+
"icon": "./icon.png",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/lehua-art/dsh-folia.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"dsh-plugin",
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"wallpaper",
|
|
22
|
+
"folia",
|
|
23
|
+
"theme",
|
|
24
|
+
"background"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./lib/types/index.d.ts",
|
|
32
|
+
"default": "./lib/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./client": {
|
|
35
|
+
"types": "./lib/types/client.d.ts",
|
|
36
|
+
"default": "./lib/client.js"
|
|
37
|
+
},
|
|
38
|
+
"./subpackage-boot.js": "./lib/subpackage-boot.js",
|
|
39
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"icon.png",
|
|
44
|
+
"lib/index.js",
|
|
45
|
+
"lib/host-boot.js",
|
|
46
|
+
"lib/log.js",
|
|
47
|
+
"lib/notice.js",
|
|
48
|
+
"lib/kernel/",
|
|
49
|
+
"lib/kernel/data-dir.js",
|
|
50
|
+
"lib/kernel/config.js",
|
|
51
|
+
"lib/kernel/host-kernel.js",
|
|
52
|
+
"lib/kernel/host-session.js",
|
|
53
|
+
"lib/boot/",
|
|
54
|
+
"lib/routes/",
|
|
55
|
+
"lib/occlusion/",
|
|
56
|
+
"lib/fontsets/",
|
|
57
|
+
"lib/glass-presets/",
|
|
58
|
+
"lib/about/",
|
|
59
|
+
"lib/component-visibility.js",
|
|
60
|
+
"lib/client.js",
|
|
61
|
+
"lib/http-body.js",
|
|
62
|
+
"lib/json-response.js",
|
|
63
|
+
"lib/pkg-read.js",
|
|
64
|
+
"lib/scene-manifest.js",
|
|
65
|
+
"lib/settings-schema.js",
|
|
66
|
+
"lib/subpackage-boot.js",
|
|
67
|
+
"lib/media/",
|
|
68
|
+
"lib/media/transcode.js",
|
|
69
|
+
"lib/media/mp4-probe.js",
|
|
70
|
+
"lib/media/scene-probe.js",
|
|
71
|
+
"lib/media/video-preview.js",
|
|
72
|
+
"lib/media/frame-store.js",
|
|
73
|
+
"lib/media/media-dirs.js",
|
|
74
|
+
"lib/media-origin.js",
|
|
75
|
+
"lib/inventory.js",
|
|
76
|
+
"lib/serve.js",
|
|
77
|
+
"lib/faststart.js",
|
|
78
|
+
"lib/cache-dir.js",
|
|
79
|
+
"lib/upload-store.js",
|
|
80
|
+
"lib/diag-trace.js",
|
|
81
|
+
"lib/http-lifecycle.js",
|
|
82
|
+
"lib/mp4-vfs.js",
|
|
83
|
+
"lib/startup-sweep.js",
|
|
84
|
+
"lib/fs-paths.js",
|
|
85
|
+
"lib/upload-dir.js",
|
|
86
|
+
"lib/wallpaper-scan.js",
|
|
87
|
+
"lib/steam-discovery.js",
|
|
88
|
+
"lib/webwallgl/",
|
|
89
|
+
"lib/we-assets.js",
|
|
90
|
+
"lib/shim-seed.js",
|
|
91
|
+
"lib/we-props.js",
|
|
92
|
+
"lib/we-focus-guard.js",
|
|
93
|
+
"lib/types/index.d.ts",
|
|
94
|
+
"lib/types/client.d.ts",
|
|
95
|
+
"cordis.patch.yml",
|
|
96
|
+
"assets/readme/hero-light.png",
|
|
97
|
+
"assets/readme/hero-dark.png",
|
|
98
|
+
"assets/readme/key-lang-zh-light.png",
|
|
99
|
+
"assets/readme/key-lang-zh-dark.png",
|
|
100
|
+
"assets/readme/key-lang-en-light.png",
|
|
101
|
+
"assets/readme/key-lang-en-dark.png",
|
|
102
|
+
"assets/readme/key-lang-start-light.png",
|
|
103
|
+
"assets/readme/key-lang-start-dark.png",
|
|
104
|
+
"assets/readme/key-get-light.png",
|
|
105
|
+
"assets/readme/key-get-dark.png",
|
|
106
|
+
"assets/readme/key-setup-light.png",
|
|
107
|
+
"assets/readme/key-setup-dark.png",
|
|
108
|
+
"assets/readme/key-docs-light.png",
|
|
109
|
+
"assets/readme/key-docs-dark.png",
|
|
110
|
+
"assets/readme/key-release-light.png",
|
|
111
|
+
"assets/readme/key-release-dark.png",
|
|
112
|
+
"assets/readme/stat-version-light.png",
|
|
113
|
+
"assets/readme/stat-version-dark.png",
|
|
114
|
+
"assets/readme/stat-formats-light.png",
|
|
115
|
+
"assets/readme/stat-formats-dark.png",
|
|
116
|
+
"assets/readme/stat-mascots-light.png",
|
|
117
|
+
"assets/readme/stat-mascots-dark.png",
|
|
118
|
+
"assets/readme/stat-modules-light.png",
|
|
119
|
+
"assets/readme/stat-modules-dark.png",
|
|
120
|
+
"assets/readme/section-intro-light.png",
|
|
121
|
+
"assets/readme/section-intro-dark.png",
|
|
122
|
+
"assets/readme/section-preview-light.png",
|
|
123
|
+
"assets/readme/section-preview-dark.png",
|
|
124
|
+
"assets/readme/section-start-light.png",
|
|
125
|
+
"assets/readme/section-start-dark.png",
|
|
126
|
+
"assets/readme/section-index-light.png",
|
|
127
|
+
"assets/readme/section-index-dark.png",
|
|
128
|
+
"assets/readme/section-features-light.png",
|
|
129
|
+
"assets/readme/section-features-dark.png",
|
|
130
|
+
"assets/readme/section-formats-light.png",
|
|
131
|
+
"assets/readme/section-formats-dark.png",
|
|
132
|
+
"assets/readme/section-install-light.png",
|
|
133
|
+
"assets/readme/section-install-dark.png",
|
|
134
|
+
"assets/readme/section-usage-light.png",
|
|
135
|
+
"assets/readme/section-usage-dark.png",
|
|
136
|
+
"assets/readme/section-pet-light.png",
|
|
137
|
+
"assets/readme/section-pet-dark.png",
|
|
138
|
+
"assets/readme/section-config-light.png",
|
|
139
|
+
"assets/readme/section-config-dark.png",
|
|
140
|
+
"assets/readme/section-compat-light.png",
|
|
141
|
+
"assets/readme/section-compat-dark.png",
|
|
142
|
+
"assets/readme/section-develop-light.png",
|
|
143
|
+
"assets/readme/section-develop-dark.png",
|
|
144
|
+
"assets/readme/section-community-light.png",
|
|
145
|
+
"assets/readme/section-community-dark.png",
|
|
146
|
+
"assets/readme/section-credits-light.png",
|
|
147
|
+
"assets/readme/section-credits-dark.png",
|
|
148
|
+
"assets/readme/section-support-light.png",
|
|
149
|
+
"assets/readme/section-support-dark.png",
|
|
150
|
+
"assets/readme/section-stars-light.png",
|
|
151
|
+
"assets/readme/section-stars-dark.png",
|
|
152
|
+
"assets/readme/SOURCES.md",
|
|
153
|
+
"assets/readme/OFL-Jersey20.txt",
|
|
154
|
+
"assets/readme/mascot-eating-rice-1280x1784.jpg",
|
|
155
|
+
"README.md",
|
|
156
|
+
"README.en.md",
|
|
157
|
+
"README.beginner.md",
|
|
158
|
+
"lib/components/",
|
|
159
|
+
"lib/features.js",
|
|
160
|
+
"lib/route-registry.js",
|
|
161
|
+
"lib/settings/",
|
|
162
|
+
"lib/routes/skins.js"
|
|
163
|
+
],
|
|
164
|
+
"dsh": {
|
|
165
|
+
"bundle": {
|
|
166
|
+
"patch": "./cordis.patch.yml"
|
|
167
|
+
},
|
|
168
|
+
"client": {
|
|
169
|
+
"inject": [
|
|
170
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
171
|
+
],
|
|
172
|
+
"platform": "web",
|
|
173
|
+
"immediately": true
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"license": "MIT",
|
|
177
|
+
"peerDependencies": {
|
|
178
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
179
|
+
"@deepseek-ai/dsh-client-runtime": ">=0.1.5-rc.1",
|
|
180
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.5-rc.1",
|
|
181
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.5-rc.1",
|
|
182
|
+
"react": "^18.2.0"
|
|
183
|
+
},
|
|
184
|
+
"peerDependenciesMeta": {
|
|
185
|
+
"@deepseek-ai/dsh-client-runtime": {
|
|
186
|
+
"optional": true
|
|
187
|
+
},
|
|
188
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
189
|
+
"optional": true
|
|
190
|
+
},
|
|
191
|
+
"@deepseek-ai/dsh-host-webserver": {
|
|
192
|
+
"optional": true
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"dependencies": {
|
|
196
|
+
"webwallgl": "^3.0.0",
|
|
197
|
+
"x11": "^4.3.1"
|
|
198
|
+
},
|
|
199
|
+
"overrides": {
|
|
200
|
+
"js-yaml": "4.3.2"
|
|
201
|
+
},
|
|
202
|
+
"devDependencies": {
|
|
203
|
+
"@eslint/js": "^10.0.1",
|
|
204
|
+
"@types/node": "^26.6.5",
|
|
205
|
+
"eslint": "^10.12.0",
|
|
206
|
+
"typescript": "^7.0.2"
|
|
207
|
+
},
|
|
208
|
+
"scripts": {
|
|
209
|
+
"build": "node scripts/build-client.mjs",
|
|
210
|
+
"install:source": "node scripts/install-source.mjs",
|
|
211
|
+
"install:release": "node scripts/install-release.mjs",
|
|
212
|
+
"pack:release": "node scripts/pack-release.mjs",
|
|
213
|
+
"verify:deployment": "node test/verify-deployment.mjs",
|
|
214
|
+
"verify:pet-link": "node test/verify-pet-link.mjs",
|
|
215
|
+
"verify:pet-link:integration": "node test/verify-pet-hotplug-integration.mjs",
|
|
216
|
+
"verify:pet-link:browser": "node test/verify-pet-tuner-browser.mjs",
|
|
217
|
+
"verify": "node test/run-guards.mjs verify",
|
|
218
|
+
"verify:docs": "node test/run-guards.mjs docs",
|
|
219
|
+
"verify:bridge": "node test/verify-bridge-ci.mjs --provision",
|
|
220
|
+
"verify:bridge:strict": "node test/verify-media-bridge.mjs --provision",
|
|
221
|
+
"verify:e2e": "node test/e2e-web-media-origin.mjs",
|
|
222
|
+
"lint": "eslint .",
|
|
223
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
224
|
+
"verify:static": "node test/verify-static.mjs",
|
|
225
|
+
"smoke": "node test/run-guards.mjs smoke",
|
|
226
|
+
"verify:all": "node test/run-guards.mjs all",
|
|
227
|
+
"test:boot": "node test/boot-http.mjs",
|
|
228
|
+
"test:pets": "node test/pets-http.mjs",
|
|
229
|
+
"test:pets-layer": "node test/pets-layer.mjs",
|
|
230
|
+
"test:features": "node test/verify-features.mjs",
|
|
231
|
+
"audit": "node test/run-audit.mjs",
|
|
232
|
+
"gate": "node scripts/build-client.mjs && node test/run-guards.mjs smoke && node test/verify-features.mjs && node test/verify-route-registry.mjs && node test/verify-settings-ownership.mjs && node test/boot-http.mjs && node test/pets-http.mjs && node test/pets-layer.mjs"
|
|
233
|
+
}
|
|
234
234
|
}
|