dsh-folia 4.8.2 → 4.9.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 +154 -1
- package/lib/features.js +4 -0
- package/lib/index.js +3 -1
- package/lib/route-registry.js +12 -0
- package/lib/routes/site-package.js +222 -0
- package/lib/zip-read.js +142 -0
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -780,6 +780,17 @@ 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
|
+
// ── 站点工程包(zip):点击即解码的文案(4.9.0)──────────────────────────
|
|
785
|
+
"正在解码工程包…(首次会下载,之后立即生效)": "Decoding the project package… (downloads on first use, instant afterwards)",
|
|
786
|
+
"这个工程包是可执行程序(application),不能当壁纸": "This package is an application, not a wallpaper",
|
|
787
|
+
"解码失败:{code}": "Decode failed: {code}",
|
|
788
|
+
"已就绪:{name}": "Ready: {name}",
|
|
789
|
+
"已解码,但清单里还没出现 —— 点「刷新」重试": "Decoded, but it has not shown up yet — press Refresh",
|
|
790
|
+
"{n} 个站点工程包 · 点一下就能当背景(首次会下载)": "{n} site project packages · one click and it becomes your background (downloads on first use)",
|
|
791
|
+
"点击解码并应用(首次需下载)": "Click to decode and apply (downloads the first time)",
|
|
792
|
+
"解码中…": "Decoding…",
|
|
793
|
+
"工程包 · 点击解码": "Project package · click to decode",
|
|
783
794
|
};
|
|
784
795
|
|
|
785
796
|
// ── 内联模块:src/i18n-copy-picker.js —— 英文词表域:壁纸选择器与快捷面板(picker-* · quick-panel · sidebar-right · theme-follow)(构建期注入;勿手改本段)──
|
|
@@ -4621,6 +4632,41 @@ window.__ModuleLoader__.load({
|
|
|
4621
4632
|
/* Scene-wallpaper "实时帧" badge — top-right under the hide button.
|
|
4622
4633
|
作用域钉在**缩略图卡片**里:卡头的可播放计数徽标(.we-picker__card-head 下)
|
|
4623
4634
|
同名,不能被这条 absolute 角标规则盖掉(那一枚走 .we-picker__card-badge 的基样式,行内计数)。 */
|
|
4635
|
+
/* ── 站点工程包条带(.we-picker__pkg-*)────────────────────────────────────
|
|
4636
|
+
为什么单列一条:工程包(zip)不能进 <video>,点它要先解码一次。
|
|
4637
|
+
形态上刻意与网格卡片区分(横向小卡 + 明确的"点击解码"徽标),
|
|
4638
|
+
避免用户把它当成"点了立刻切的壁纸"而误以为失败。 */
|
|
4639
|
+
.we-picker__pkg-strip { margin: 6px 0 10px; }
|
|
4640
|
+
.we-picker__pkg-row {
|
|
4641
|
+
display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 6px;
|
|
4642
|
+
scrollbar-width: thin;
|
|
4643
|
+
}
|
|
4644
|
+
.we-picker__pkg {
|
|
4645
|
+
position: relative; flex: 0 0 auto;
|
|
4646
|
+
width: 168px; padding: 6px;
|
|
4647
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
4648
|
+
border-radius: 8px; cursor: pointer;
|
|
4649
|
+
background: rgba(255, 255, 255, 0.06);
|
|
4650
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
4651
|
+
transition: background 120ms linear, border-color 120ms linear;
|
|
4652
|
+
}
|
|
4653
|
+
.we-picker__pkg:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.24); }
|
|
4654
|
+
.we-picker__pkg.is-busy { opacity: 0.7; cursor: progress; }
|
|
4655
|
+
.we-picker__pkg-thumb {
|
|
4656
|
+
display: block; width: 100%; aspect-ratio: 16 / 9;
|
|
4657
|
+
border-radius: 6px; background-size: cover; background-position: center;
|
|
4658
|
+
background-color: rgba(0, 0, 0, 0.25);
|
|
4659
|
+
}
|
|
4660
|
+
.we-picker__pkg-title {
|
|
4661
|
+
font-size: 0.72em; line-height: 1.35; max-height: 2.7em;
|
|
4662
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
4663
|
+
}
|
|
4664
|
+
.we-picker__pkg-badge {
|
|
4665
|
+
font-size: 0.66em; opacity: 0.85;
|
|
4666
|
+
padding: 1px 6px; border-radius: 4px;
|
|
4667
|
+
background: rgba(30, 90, 160, 0.55);
|
|
4668
|
+
align-self: flex-start;
|
|
4669
|
+
}
|
|
4624
4670
|
.we-picker__card .we-picker__card-badge {
|
|
4625
4671
|
position: absolute; top: 4px; right: 4px; z-index: 1;
|
|
4626
4672
|
padding: 1px 6px; font-size: 0.62em; line-height: 1.6;
|
|
@@ -16437,6 +16483,21 @@ window.__ModuleLoader__.load({
|
|
|
16437
16483
|
);
|
|
16438
16484
|
}
|
|
16439
16485
|
|
|
16486
|
+
/**
|
|
16487
|
+
* **可安装的工程包**(站点上的 WE 工程包 zip):`type === "package"`。
|
|
16488
|
+
*
|
|
16489
|
+
* 为什么不并进 `playableList`:网格的语义是"点一下就切过去",而工程包要先解码一次
|
|
16490
|
+
* (下载 + 解包 + 登记成 WE 工程目录)才能播。混进网格会让轮播候选与"可播放"计数都失真。
|
|
16491
|
+
* 所以它自己一档,由选择器在网格上方单独渲染一条条带,点击走 `onPickCard` 的解码分支。
|
|
16492
|
+
*/
|
|
16493
|
+
function packageWallpapers(wallpapers, ratingFilter, typeFilter, hiddenIds, sourceFilter) {
|
|
16494
|
+
return wallpapers.filter((w) => w && w.type === "package"
|
|
16495
|
+
&& !isHiddenWallpaper(w.id, hiddenIds)
|
|
16496
|
+
&& matchesRatingFilter(w, ratingFilter)
|
|
16497
|
+
&& matchesSourceFilter(w, sourceFilter)
|
|
16498
|
+
&& (typeFilter === "all" || typeFilter === "package" || typeFilter === "video"));
|
|
16499
|
+
}
|
|
16500
|
+
|
|
16440
16501
|
/** 已隐藏列表(保持库存顺序)。 */
|
|
16441
16502
|
function hiddenWallpapers(wallpapers, hiddenIds) {
|
|
16442
16503
|
return wallpapers.filter((w) => isHiddenWallpaper(w.id, hiddenIds));
|
|
@@ -16469,6 +16530,9 @@ window.__ModuleLoader__.load({
|
|
|
16469
16530
|
typeCounts[w.type] = (typeCounts[w.type] || 0) + 1;
|
|
16470
16531
|
}
|
|
16471
16532
|
const editorList = playableWallpapers(list, ratingFilter, typeFilter, hiddenIds, sourceFilter);
|
|
16533
|
+
// 工程包条带(见 packageWallpapers 的注释):只受分级/来源过滤,不受"可播放"闸门影响。
|
|
16534
|
+
const packageList = packageWallpapers(list, ratingFilter, typeFilter, hiddenIds, sourceFilter)
|
|
16535
|
+
.filter((w) => !query || String(w.title || "").toLowerCase().indexOf(query) !== -1);
|
|
16472
16536
|
const hiddenList = hiddenWallpapers(list, hiddenIds);
|
|
16473
16537
|
return {
|
|
16474
16538
|
query,
|
|
@@ -16477,6 +16541,8 @@ window.__ModuleLoader__.load({
|
|
|
16477
16541
|
ratingCounts,
|
|
16478
16542
|
typeCounts,
|
|
16479
16543
|
editorList,
|
|
16544
|
+
packageList,
|
|
16545
|
+
packageCount: packageList.length,
|
|
16480
16546
|
hiddenList,
|
|
16481
16547
|
sourceCounts,
|
|
16482
16548
|
normalPage: pageSlice(playableList, input.page),
|
|
@@ -16733,6 +16799,42 @@ window.__ModuleLoader__.load({
|
|
|
16733
16799
|
);
|
|
16734
16800
|
}
|
|
16735
16801
|
|
|
16802
|
+
/**
|
|
16803
|
+
* 站点工程包条带(`type === "package"`):它们不能被 <video> 播,点一下要先解码。
|
|
16804
|
+
*
|
|
16805
|
+
* 为什么不放进网格:网格语义是"点一下就切过去",包里没有可直接播的媒体;混进去会让
|
|
16806
|
+
* "可播放 N 张"的计数与轮播候选都失真。所以独立一条,文案也直说"会下载一次"。
|
|
16807
|
+
*/
|
|
16808
|
+
function renderPackageStrip(ctx) {
|
|
16809
|
+
const { packageList, packageInstalling, packageNote, sel } = ctx;
|
|
16810
|
+
if (!packageList || packageList.length === 0) return null;
|
|
16811
|
+
const cards = packageList.map((w) => React.createElement("div", {
|
|
16812
|
+
key: "pkg-" + w.id,
|
|
16813
|
+
role: "button",
|
|
16814
|
+
tabIndex: 0,
|
|
16815
|
+
className: "we-picker__pkg" + (packageInstalling === w.id ? " is-busy" : ""),
|
|
16816
|
+
title: weT("点击解码并应用(首次需下载)"),
|
|
16817
|
+
onClick: () => ctx.onPickCard(w),
|
|
16818
|
+
onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ctx.onPickCard(w); } },
|
|
16819
|
+
},
|
|
16820
|
+
w.preview ? React.createElement("span", {
|
|
16821
|
+
className: "we-picker__pkg-thumb",
|
|
16822
|
+
style: { backgroundImage: "url(" + JSON.stringify(w.preview) + ")" },
|
|
16823
|
+
}) : null,
|
|
16824
|
+
React.createElement("span", { className: "we-picker__pkg-title" }, String(w.title || "")),
|
|
16825
|
+
React.createElement("span", { className: "we-picker__pkg-badge" },
|
|
16826
|
+
packageInstalling === w.id ? weT("解码中…") : weT("工程包 · 点击解码")),
|
|
16827
|
+
));
|
|
16828
|
+
return React.createElement("div", { className: "we-picker__pkg-strip" },
|
|
16829
|
+
React.createElement("div", { className: "we-picker__row" },
|
|
16830
|
+
React.createElement("span", { className: "we-picker__hint" },
|
|
16831
|
+
weT("{n} 个站点工程包 · 点一下就能当背景(首次会下载)", { n: packageList.length })),
|
|
16832
|
+
),
|
|
16833
|
+
React.createElement("div", { className: "we-picker__pkg-row" }, cards),
|
|
16834
|
+
packageNote ? React.createElement("span", { className: "we-picker__hint" }, packageNote) : null,
|
|
16835
|
+
);
|
|
16836
|
+
}
|
|
16837
|
+
|
|
16736
16838
|
function renderPickerNormalBody(ctx, draft, draftIdSet, normalWin) {
|
|
16737
16839
|
const { sel, playableList, onToggleBatchMode, armedConfirm, onBatchHide, onDisarmConfirm, cdMode, gridRef, query } = ctx;
|
|
16738
16840
|
return React.createElement("div", { className: "we-picker__modal-body" },
|
|
@@ -16758,6 +16860,8 @@ window.__ModuleLoader__.load({
|
|
|
16758
16860
|
weT("隐藏选中的 {n} 张壁纸?可在「已隐藏」中随时恢复。", { n: sel.batchSelected.length }),
|
|
16759
16861
|
onBatchHide, onDisarmConfirm),
|
|
16760
16862
|
renderPickerFilterRow(ctx),
|
|
16863
|
+
// 工程包条带:在网格之上、过滤行之下 —— 它属于"从这里挑",但不参与网格的虚拟化计数。
|
|
16864
|
+
renderPackageStrip(ctx),
|
|
16761
16865
|
React.createElement("div", { ref: cdMode ? undefined : gridRef, className: "we-picker__grid" },
|
|
16762
16866
|
// "Close wallpaper" card — equivalent of the first <option> in the
|
|
16763
16867
|
// select-based UI. Rendered as a <div role="button"> like every other card:
|
|
@@ -30381,7 +30485,50 @@ window.__ModuleLoader__.load({
|
|
|
30381
30485
|
};
|
|
30382
30486
|
const onBatchCancel = () => { disarmConfirm(); setTransient("batchMode", false); setTransient("batchSelected", []); emit(); };
|
|
30383
30487
|
const onSearchInput = (e) => { setTransient("search", e.target.value); emit(); };
|
|
30488
|
+
/**
|
|
30489
|
+
* 把一个站点工程包**解码成本机 WE 工程目录**并立刻应用("点一下就用")。
|
|
30490
|
+
*
|
|
30491
|
+
* 为什么放在点击里而不是"先下载再点":用户要的是"点这张壁纸就是背景"。
|
|
30492
|
+
* 第一次点会多等几秒(下载 + 解包),之后同一条目已在盘上 ⇒ 宿主直接 reused,几乎瞬时。
|
|
30493
|
+
*/
|
|
30494
|
+
async function installSitePackage(w) {
|
|
30495
|
+
selection.packageInstalling = w.id;
|
|
30496
|
+
selection.packageNote = weT("正在解码工程包…(首次会下载,之后立即生效)");
|
|
30497
|
+
emit();
|
|
30498
|
+
try {
|
|
30499
|
+
const res = await apiPostJson("/site-package/install", { url: w.media, name: w.title }, { parse: "always" });
|
|
30500
|
+
const d = (res && res.data) || null;
|
|
30501
|
+
if (!(res && res.ok && d && d.ok)) {
|
|
30502
|
+
const code = (d && d.error) || "failed";
|
|
30503
|
+
selection.packageNote = code === "application-package"
|
|
30504
|
+
? weT("这个工程包是可执行程序(application),不能当壁纸")
|
|
30505
|
+
: weT("解码失败:{code}", { code: String(code) });
|
|
30506
|
+
return;
|
|
30507
|
+
}
|
|
30508
|
+
await loadInventory();
|
|
30509
|
+
const list = (selection.inventory && selection.inventory.wallpapers) || [];
|
|
30510
|
+
const target = list.find((x) => x && x.id === d.id);
|
|
30511
|
+
if (target) {
|
|
30512
|
+
selection.packageNote = weT("已就绪:{name}", { name: target.title || w.title || "" });
|
|
30513
|
+
applySelection(target.id, { fromManual: true });
|
|
30514
|
+
} else {
|
|
30515
|
+
selection.packageNote = weT("已解码,但清单里还没出现 —— 点「刷新」重试");
|
|
30516
|
+
}
|
|
30517
|
+
} catch (e) {
|
|
30518
|
+
selection.packageNote = weT("解码失败:{code}", { code: (e && e.message) ? e.message : weT("网络错误") });
|
|
30519
|
+
} finally {
|
|
30520
|
+
selection.packageInstalling = "";
|
|
30521
|
+
emit();
|
|
30522
|
+
}
|
|
30523
|
+
}
|
|
30524
|
+
|
|
30384
30525
|
const onPickCard = (w) => {
|
|
30526
|
+
// 工程包(站点上的 WE 工程包 zip):点它不该"切过去"—— 它还不能播。
|
|
30527
|
+
// 这里的语义是「解码一次,然后切过去」:用户看到的是"点一下就能用",没有单独的下载步骤。
|
|
30528
|
+
if (w && w.type === "package" && !selection.batchMode && !(selection.pickerDraft && selection.editing)) {
|
|
30529
|
+
void installSitePackage(w);
|
|
30530
|
+
return;
|
|
30531
|
+
}
|
|
30385
30532
|
// 轮播编辑器的下钻(pickerDraft):点卡片 = 加入/移出**草稿**,不切当前壁纸;
|
|
30386
30533
|
// 草稿对象被就地增删(与 importPlaylistIntoDraft / 面板编辑器同一口径),
|
|
30387
30534
|
// 「保存」时才由 saveEditingGroup 落盘。
|
|
@@ -30730,7 +30877,7 @@ window.__ModuleLoader__.load({
|
|
|
30730
30877
|
// 进去、把结果取出来 —— 组件体不再就地算这些(分级/类型/隐藏的判定也在那边)。
|
|
30731
30878
|
const {
|
|
30732
30879
|
query, playableList, basePlayable, ratingCounts, typeCounts, sourceCounts, hiddenList,
|
|
30733
|
-
editorPageView,
|
|
30880
|
+
packageList, packageCount, editorPageView,
|
|
30734
30881
|
} = pickerModel({
|
|
30735
30882
|
wallpapers: list,
|
|
30736
30883
|
hiddenIds: selection.hiddenIds,
|
|
@@ -30903,6 +31050,12 @@ window.__ModuleLoader__.load({
|
|
|
30903
31050
|
onClear, onRatingFilterChange, onTypeFilterChange, onSourceFilterChange,
|
|
30904
31051
|
onShowNormalView, onShowHiddenView,
|
|
30905
31052
|
onToggleBatchMode, onArmBatchHide, onBatchHide, onBatchCancel, onSearchInput, onPickCard,
|
|
31053
|
+
// 站点工程包(type=package):来自上面 pickerModel 的解构值(本作用域已有),
|
|
31054
|
+
// 点击走 onPickCard 的解码分支(installSitePackage)。
|
|
31055
|
+
packageList: packageList || [],
|
|
31056
|
+
packageCount: packageCount || 0,
|
|
31057
|
+
packageInstalling: sel.packageInstalling || "",
|
|
31058
|
+
packageNote: sel.packageNote || "",
|
|
30906
31059
|
}) : React.createElement(React.Fragment, null,
|
|
30907
31060
|
// 页签内容 → **每个页签末尾一个重置**(issue #192 用户口径:重置指定选项卡)→
|
|
30908
31061
|
// 全局重置(只在「系统」页签末尾,即设置页里最接近"最底部"的位置)。
|
package/lib/features.js
CHANGED
|
@@ -37,6 +37,10 @@ export const FEATURES = [
|
|
|
37
37
|
role: '本机/上传/站点三源合并成壁纸库;着色、图层、播放与轮换由客户端接管',
|
|
38
38
|
host: [
|
|
39
39
|
'lib/routes/inventory.js',
|
|
40
|
+
// 站点工程包(WE 工程包 zip)→ 本机 WE 工程目录:点卡片时"解码一次就能用"。
|
|
41
|
+
// 产物仍是壁纸,所以归壁纸功能;zip 读取器是它专用的安全入口。
|
|
42
|
+
'lib/routes/site-package.js',
|
|
43
|
+
'lib/zip-read.js',
|
|
40
44
|
'lib/routes/media-bytes.js',
|
|
41
45
|
'lib/routes/media-derived.js',
|
|
42
46
|
'lib/routes/scene-frame.js',
|
package/lib/index.js
CHANGED
|
@@ -158,7 +158,7 @@ const {
|
|
|
158
158
|
sceneVideoProbeKey, sceneVideoProbeSet, getMediaInfo, ensureDirOnce, customFrameDir, avatarDir,
|
|
159
159
|
mascotDir, ensureUploadDir, transcodeToFps, registerTranscodeWaiter, transcodeCachePathFor,
|
|
160
160
|
transcodeJobs, readUploadMeta, metaEntry, setUploadMeta, removeUploadMeta, resolveUploadFile,
|
|
161
|
-
setUploadDir, UPLOAD_MAX_BYTES, WEBWALLGL_DIR, WEBWALLGL_PKG_DIR, GLASS_PRESET_BUILTIN_DIR,
|
|
161
|
+
setUploadDir, getUploadDir, UPLOAD_MAX_BYTES, WEBWALLGL_DIR, WEBWALLGL_PKG_DIR, GLASS_PRESET_BUILTIN_DIR,
|
|
162
162
|
WE_ASSETS_SOURCE_ID, weAssetsAvailable, getWeAssetsDir, setWeAssetsDir, listWeAssetNames,
|
|
163
163
|
userPropsFor, isBetterSidebarLoaded, GPU_FRAME_MAX_BYTES, GPU_FRAME_AREA_TOL, GPU_WRITE_INFLIGHT, gpuFrameFileFor,
|
|
164
164
|
looksLikePng, pngSizeOf, pngDimsOfBuf, sceneFrameSlot, generateVideoPreview, ensureImagePreview,
|
|
@@ -294,6 +294,8 @@ export function apply(ctx) {
|
|
|
294
294
|
registerFeatureRoutes(webServer, {
|
|
295
295
|
disposers, base: BASE, log, mediaOriginApi, buildInventory,
|
|
296
296
|
componentProbe, ensureImagePreview,
|
|
297
|
+
// 站点工程包安装要落到"当前"存储位置:传访问器,不传值。
|
|
298
|
+
getUploadDir,
|
|
297
299
|
});
|
|
298
300
|
|
|
299
301
|
// 启动预热(有意不阻塞):站点清单与模块索引各拉一次,让第一次打开面板不冷。
|
package/lib/route-registry.js
CHANGED
|
@@ -20,6 +20,7 @@ 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
22
|
import { registerPetsRoutes } from './routes/pets.js';
|
|
23
|
+
import { registerSitePackageRoutes } from './routes/site-package.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* 已迁移的功能族。
|
|
@@ -71,6 +72,17 @@ 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: 'site-package',
|
|
77
|
+
file: 'lib/routes/site-package.js',
|
|
78
|
+
register: registerSitePackageRoutes,
|
|
79
|
+
// 站点工程包要"解到本机存储位置",所以必须拿到**上传目录访问器**(不是值快照:
|
|
80
|
+
// 用户中途改目录后,安装应该落到新目录)。siteBase 缺省时路由自己回落到站点默认基址。
|
|
81
|
+
deps: (c) => ({
|
|
82
|
+
disposers: c.disposers, base: c.base, log: c.log,
|
|
83
|
+
getUploadDir: c.getUploadDir, siteBase: c.siteBase,
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
74
86
|
];
|
|
75
87
|
|
|
76
88
|
/**
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/routes/site-package.js — **站点工程包 → 本机工程目录**(点击即解码的那一半)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:站点上有大量 wallpaper-engine **工程包**(zip:`project.json` + `scene.pkg` / `*.mp4`)。
|
|
5
|
+
* 它不能被 `<video>` 直接播 —— 用户看到的是「浏览器无法解码这段视频」。但它们**能**用:
|
|
6
|
+
* 本机已经用同一套管线渲染着几百个 WE 场景(Steam 创意工坊那批),差别只是文件还压在 zip 里。
|
|
7
|
+
*
|
|
8
|
+
* 所以这里只做一件事:**把 zip 解成"本机 WE 工程目录"**,落在存储位置(上传目录)下,
|
|
9
|
+
* 命名 `up-dir-<短名>-<hash6>`。落盘之后:
|
|
10
|
+
* · `lib/upload-dir.js#enumerateUploadsP` 会自动认领它(它认"含 project.json 的目录"),
|
|
11
|
+
* · 于是下一次 `/folia/inventory` 里它就变成 `type: scene|video|web` 的普通壁纸,
|
|
12
|
+
* · 走现有场景/视频/web 渲染管线播放。
|
|
13
|
+
*
|
|
14
|
+
* 客户端因此可以做到「点一下就用」:点卡片 → 本路由解码 → 清单刷新 → 自动选中刚解出来的那条。
|
|
15
|
+
* 对用户而言没有"下载/安装"这一步,只有第一次点的时候多等一下。
|
|
16
|
+
*
|
|
17
|
+
* 安全(每条都有测试):
|
|
18
|
+
* · **只下我们自己的站点**:URL 的 host 必须在白名单里(默认站点基址),挡 SSRF;
|
|
19
|
+
* · 只认 zip 魔数,解包走 `lib/zip-read.js`(拒路径穿越、限量、拒炸弹);
|
|
20
|
+
* · 只写 `project.json` + 工程内文件,不写可执行/脚本;
|
|
21
|
+
* · 幂等:同 URL 已解过就直接返回同一个 id(不重复占盘)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { readZipEntries, safeNameFromUrl } from '../zip-read.js';
|
|
28
|
+
|
|
29
|
+
export const DEPS = ['disposers', 'base', 'getUploadDir', 'log', 'siteBase'];
|
|
30
|
+
|
|
31
|
+
/** 下载上限:工程包实测 3–56MB,留到 256MB;再大就不是"点击即用"的场景了。 */
|
|
32
|
+
const MAX_ZIP_BYTES = 256 * 1024 * 1024;
|
|
33
|
+
const DOWNLOAD_TIMEOUT_MS = 120000;
|
|
34
|
+
|
|
35
|
+
/** 站点的默认基址(与 inventory 同一个真源语义:以 inventory 的解析为准,这里只作兜底)。 */
|
|
36
|
+
const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
|
|
37
|
+
|
|
38
|
+
/** 把任意 URL 归一成"允许下载的"绝对 URL;不允许就返回 null。 */
|
|
39
|
+
function allowedDownloadUrl(raw, allowHosts) {
|
|
40
|
+
let u;
|
|
41
|
+
try { u = new URL(String(raw)); } catch { return null; }
|
|
42
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
43
|
+
const host = u.host.toLowerCase();
|
|
44
|
+
if (!allowHosts.includes(host)) return null;
|
|
45
|
+
return u;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 流式下载并限量(Content-Length 可能缺失,所以边下边数)。 */
|
|
49
|
+
async function fetchZip(url, maxBytes = MAX_ZIP_BYTES) {
|
|
50
|
+
const ctrl = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/zip,application/octet-stream,*/*' } });
|
|
54
|
+
if (!res.ok) return { ok: false, error: 'http-' + res.status };
|
|
55
|
+
const len = Number(res.headers.get('content-length') || 0);
|
|
56
|
+
if (len && len > maxBytes) return { ok: false, error: 'too-large' };
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let total = 0;
|
|
59
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
60
|
+
const reader = res.body.getReader();
|
|
61
|
+
for (;;) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done) break;
|
|
64
|
+
total += value.length;
|
|
65
|
+
if (total > maxBytes) { try { await reader.cancel(); } catch { /* ignore */ } return { ok: false, error: 'too-large' }; }
|
|
66
|
+
chunks.push(Buffer.from(value));
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
70
|
+
total = buf.length;
|
|
71
|
+
if (total > maxBytes) return { ok: false, error: 'too-large' };
|
|
72
|
+
chunks.push(buf);
|
|
73
|
+
}
|
|
74
|
+
const buf = Buffer.concat(chunks);
|
|
75
|
+
// 魔数:只认 zip('PK\x03\x04')与空包('PK\x05\x06')。
|
|
76
|
+
const magic = buf.length >= 4 ? buf.readUInt32LE(0) : 0;
|
|
77
|
+
if (magic !== 0x04034b50 && magic !== 0x06054b50) return { ok: false, error: 'not-a-zip' };
|
|
78
|
+
return { ok: true, buf, bytes: total };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, error: e && e.name === 'AbortError' ? 'timeout' : 'network' };
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 目录名:`up-dir-<短名>-<hash6>`。短名只留 ASCII(中文标题不进文件名,避免各平台差异)。 */
|
|
87
|
+
function dirNameFor(url, name) {
|
|
88
|
+
const stem = safeNameFromUrl(url, 'pkg');
|
|
89
|
+
const hint = String(name || '').replace(/[^a-zA-Z0-9-]/g, '').slice(0, 24);
|
|
90
|
+
let h = 0;
|
|
91
|
+
const s = String(url);
|
|
92
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
93
|
+
const hash = h.toString(16).padStart(6, '0').slice(0, 6);
|
|
94
|
+
return 'up-dir-' + (hint || stem) + '-' + hash;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 读工程描述(project.json);缺失/坏 JSON 返回 null。 */
|
|
98
|
+
function readProjectJson(dir) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = readFileSync(join(dir, 'project.json'), 'utf8');
|
|
101
|
+
const p = JSON.parse(raw);
|
|
102
|
+
return p && typeof p === 'object' ? p : null;
|
|
103
|
+
} catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 解包落盘。只写 zip 里除目录之外的全部条目(工程包里的 scene.pkg / mp4 / tex / json 都需要),
|
|
108
|
+
* 但**拒绝对可执行文件的写入**:扩展名在白名单外的一律跳过(正常工程包不会带 exe/dll/bat)。
|
|
109
|
+
*/
|
|
110
|
+
const SKIP_EXEC_RE = /\.(exe|dll|bat|cmd|com|scr|ps1|sh|msi|vbs|js|jse|wsf|hta|lnk|jar)$/i;
|
|
111
|
+
|
|
112
|
+
function extractInto(dir, entries) {
|
|
113
|
+
mkdirSync(dir, { recursive: true });
|
|
114
|
+
let written = 0;
|
|
115
|
+
const skipped = [];
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
if (e.isDir) continue;
|
|
118
|
+
if (SKIP_EXEC_RE.test(e.name)) { skipped.push(e.name); continue; }
|
|
119
|
+
const target = join(dir, e.name);
|
|
120
|
+
const parent = join(target, '..');
|
|
121
|
+
mkdirSync(parent, { recursive: true });
|
|
122
|
+
writeFileSync(target, e.data);
|
|
123
|
+
written += 1;
|
|
124
|
+
}
|
|
125
|
+
return { written, skipped };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {*} webServer
|
|
130
|
+
* @param {{ disposers: any[], base: string, getUploadDir: () => string, log?: Function, siteBase?: string }} c
|
|
131
|
+
*/
|
|
132
|
+
export function registerSitePackageRoutes(webServer, c) {
|
|
133
|
+
const { disposers, base, getUploadDir, log } = c;
|
|
134
|
+
const siteBase = String(c.siteBase || DEFAULT_SITE);
|
|
135
|
+
const allowHosts = (() => {
|
|
136
|
+
try { return [new URL(siteBase).host.toLowerCase()]; } catch { return [new URL(DEFAULT_SITE).host.toLowerCase()]; }
|
|
137
|
+
})();
|
|
138
|
+
// 测试注入:允许把 127.0.0.1 加进白名单(只在显式传入时生效)。
|
|
139
|
+
if (Array.isArray(c.allowHosts)) for (const h of c.allowHosts) allowHosts.push(String(h).toLowerCase());
|
|
140
|
+
|
|
141
|
+
disposers.push(webServer.register({
|
|
142
|
+
kind: 'exact',
|
|
143
|
+
path: `${base}/site-package/install`,
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
const send = (code, payload) => {
|
|
146
|
+
res.statusCode = code;
|
|
147
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
148
|
+
res.end(JSON.stringify(payload));
|
|
149
|
+
};
|
|
150
|
+
let body = {};
|
|
151
|
+
try {
|
|
152
|
+
const text = await new Promise((resolve, reject) => {
|
|
153
|
+
let acc = '';
|
|
154
|
+
req.on('data', (d) => { acc += d; if (acc.length > 64 * 1024) reject(new Error('too-large')); });
|
|
155
|
+
req.on('end', () => resolve(acc));
|
|
156
|
+
req.on('error', reject);
|
|
157
|
+
});
|
|
158
|
+
body = text ? JSON.parse(text) : {};
|
|
159
|
+
} catch { send(400, { ok: false, error: 'bad-body' }); return; }
|
|
160
|
+
|
|
161
|
+
const url = allowedDownloadUrl(body.url, allowHosts);
|
|
162
|
+
if (!url) { send(400, { ok: false, error: 'host-not-allowed' }); return; }
|
|
163
|
+
|
|
164
|
+
const uploadDir = String(getUploadDir() || '');
|
|
165
|
+
if (!uploadDir) { send(500, { ok: false, error: 'no-upload-dir' }); return; }
|
|
166
|
+
const dir = join(uploadDir, dirNameFor(url.toString(), body.name));
|
|
167
|
+
|
|
168
|
+
// 幂等:已经解过且工程描述可读 ⇒ 直接返回("第二次点"应该立刻生效)。
|
|
169
|
+
const existing = readProjectJson(dir);
|
|
170
|
+
if (existing && existing.type && existing.type !== 'application') {
|
|
171
|
+
send(200, { ok: true, reused: true, id: 'up-dir-' + dir.split(/[\\/]/).pop().replace(/^up-dir-/, ''), type: existing.type, title: existing.title || '' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const dl = await fetchZip(url.toString());
|
|
176
|
+
if (!dl.ok) {
|
|
177
|
+
try { log && log('site-package: download failed ' + dl.error + ' ' + url.toString()); } catch { /* ignore */ }
|
|
178
|
+
// 状态码要分得清"对面有问题"与"这个包本身有问题":
|
|
179
|
+
// 不是 zip ⇒ 422(内容问题,重试也没用);超限 ⇒ 413;其余(网络/404)⇒ 502。
|
|
180
|
+
const code = dl.error === 'not-a-zip' ? 422 : (dl.error === 'too-large' ? 413 : 502);
|
|
181
|
+
send(code, { ok: false, error: dl.error });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let parsed;
|
|
186
|
+
try { parsed = readZipEntries(dl.buf); } catch (e) {
|
|
187
|
+
send(422, { ok: false, error: (e && e.code) || 'bad-zip' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const hasProject = parsed.entries.some((e) => !e.isDir && /^project\.json$/i.test(e.name));
|
|
191
|
+
if (!hasProject) { send(422, { ok: false, error: 'no-project-json' }); return; }
|
|
192
|
+
|
|
193
|
+
// 半途失败要清干净:否则下次 enumerate 会看到一个没有 project.json 的残缺目录。
|
|
194
|
+
try {
|
|
195
|
+
rmSync(dir, { recursive: true, force: true });
|
|
196
|
+
const { written, skipped } = extractInto(dir, parsed.entries);
|
|
197
|
+
const proj = readProjectJson(dir);
|
|
198
|
+
if (!proj) { rmSync(dir, { recursive: true, force: true }); send(422, { ok: false, error: 'bad-project-json' }); return; }
|
|
199
|
+
if (String(proj.type || '') === 'application') {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
send(422, { ok: false, error: 'application-package' });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
send(200, {
|
|
205
|
+
ok: true,
|
|
206
|
+
id: 'up-dir-' + dir.split(/[\\/]/).pop().replace(/^up-dir-/, ''),
|
|
207
|
+
type: String(proj.type || 'scene'),
|
|
208
|
+
title: String(proj.title || ''),
|
|
209
|
+
written,
|
|
210
|
+
skipped: skipped.length,
|
|
211
|
+
bytes: dl.bytes,
|
|
212
|
+
});
|
|
213
|
+
} catch (e) {
|
|
214
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
215
|
+
send(500, { ok: false, error: 'extract-failed', detail: e && e.message });
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 只为测试导出(纯函数,不碰 IO):白名单与目录命名是安全与幂等的两处要点。
|
|
222
|
+
export { allowedDownloadUrl, dirNameFor };
|
package/lib/zip-read.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/zip-read.js — 最小可用的 **zip 读取器**(只读,流式无关,内存里完成)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写:站点上的 WE 工程包是普通 zip(store / deflate),我们只需要"把条目读出来",
|
|
5
|
+
* 而 Node 没有内置解压。已有的 `lib/routes/pets.js#unzip` 是给宠物包定制的(只收
|
|
6
|
+
* `sounds/` 前缀、上限几 MB),不适合 56 MB 的工程包;与其在那里放宽,不如抽出通用件。
|
|
7
|
+
*
|
|
8
|
+
* 安全纪律(每条都由 `test/zip-read.mjs` 钉住):
|
|
9
|
+
* · **拒路径穿越**:条目名里出现 `..`、绝对路径、盘符、反斜杠穿越 ⇒ 整包拒绝(不是跳过 ——
|
|
10
|
+
* 一个恶意条目说明这个包不可信,不该"部分解压")。
|
|
11
|
+
* · **限量**:条目数、单条目解压后大小、总解压后大小、压缩比上限(zip bomb)。
|
|
12
|
+
* · `data` 一律返回**解压后**字节;调用方只写自己白名单内的条目。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { inflateRawSync } from 'node:zlib';
|
|
16
|
+
|
|
17
|
+
/** 默认上限:够放 336MB 的 scene.pkg,但挡住失控的解压炸弹。 */
|
|
18
|
+
export const ZIP_LIMITS = {
|
|
19
|
+
maxEntries: 4000,
|
|
20
|
+
maxEntryBytes: 512 * 1024 * 1024,
|
|
21
|
+
maxTotalBytes: 768 * 1024 * 1024,
|
|
22
|
+
// 单条目压缩比上限(解压后 / 压缩后)。正常资源很难超过 200 倍。
|
|
23
|
+
maxRatio: 200,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** 条目名是否安全(相对、无穿越)。 */
|
|
27
|
+
export function isSafeZipEntryName(name) {
|
|
28
|
+
const s = String(name || '');
|
|
29
|
+
if (!s) return false;
|
|
30
|
+
if (s.includes('\0')) return false;
|
|
31
|
+
// 统一分隔符后再判
|
|
32
|
+
const norm = s.replace(/\\/g, '/');
|
|
33
|
+
if (norm.startsWith('/')) return false;
|
|
34
|
+
if (/^[a-zA-Z]:/.test(norm)) return false; // 盘符
|
|
35
|
+
const parts = norm.split('/');
|
|
36
|
+
if (parts.some((p) => p === '..')) return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解析并解压一个 zip(缓冲)。
|
|
42
|
+
*
|
|
43
|
+
* @param {Buffer} buf
|
|
44
|
+
* @param {Partial<typeof ZIP_LIMITS>} [limits]
|
|
45
|
+
* @returns {{ entries: { name: string, data: Buffer, isDir: boolean }[], skipped: string[] }}
|
|
46
|
+
* @throws {Error} 带 `code`:`not-a-zip` / `unsafe-entry` / `too-many-entries` / `too-large` / `bad-zip`
|
|
47
|
+
*/
|
|
48
|
+
export function readZipEntries(buf, limits = {}) {
|
|
49
|
+
const lim = { ...ZIP_LIMITS, ...limits };
|
|
50
|
+
if (!Buffer.isBuffer(buf) || buf.length < 22) {
|
|
51
|
+
throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
|
|
52
|
+
}
|
|
53
|
+
// 本地文件头魔数 'PK\x03\x04';允许空包 'PK\x05\x06'。
|
|
54
|
+
const magic = buf.readUInt32LE(0);
|
|
55
|
+
if (magic !== 0x04034b50 && magic !== 0x06054b50) {
|
|
56
|
+
throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
|
|
57
|
+
}
|
|
58
|
+
// 末尾找中央目录结束记录(EOCD),最多回扫 64KB(含注释)。
|
|
59
|
+
let eocd = -1;
|
|
60
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i -= 1) {
|
|
61
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
62
|
+
}
|
|
63
|
+
if (eocd < 0) throw Object.assign(new Error('bad zip: no EOCD'), { code: 'bad-zip' });
|
|
64
|
+
|
|
65
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
66
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
67
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
68
|
+
if (count > lim.maxEntries) {
|
|
69
|
+
throw Object.assign(new Error('too many entries: ' + count), { code: 'too-many-entries' });
|
|
70
|
+
}
|
|
71
|
+
if (cdOffset + cdSize > buf.length) throw Object.assign(new Error('bad zip: CD out of range'), { code: 'bad-zip' });
|
|
72
|
+
|
|
73
|
+
const entries = [];
|
|
74
|
+
const skipped = [];
|
|
75
|
+
let total = 0;
|
|
76
|
+
let p = cdOffset;
|
|
77
|
+
for (let i = 0; i < count; i += 1) {
|
|
78
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) {
|
|
79
|
+
throw Object.assign(new Error('bad zip: bad CD entry'), { code: 'bad-zip' });
|
|
80
|
+
}
|
|
81
|
+
const method = buf.readUInt16LE(p + 10);
|
|
82
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
83
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
84
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
85
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
86
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
87
|
+
const localOffset = buf.readUInt32LE(p + 42);
|
|
88
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
89
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
90
|
+
|
|
91
|
+
if (!isSafeZipEntryName(name)) {
|
|
92
|
+
throw Object.assign(new Error('unsafe entry: ' + name), { code: 'unsafe-entry' });
|
|
93
|
+
}
|
|
94
|
+
const isDir = name.endsWith('/');
|
|
95
|
+
if (isDir) { entries.push({ name, data: Buffer.alloc(0), isDir: true }); continue; }
|
|
96
|
+
if (uncompSize > lim.maxEntryBytes) {
|
|
97
|
+
throw Object.assign(new Error('entry too large: ' + name), { code: 'too-large' });
|
|
98
|
+
}
|
|
99
|
+
total += uncompSize;
|
|
100
|
+
if (total > lim.maxTotalBytes) throw Object.assign(new Error('archive too large'), { code: 'too-large' });
|
|
101
|
+
|
|
102
|
+
// 本地头的名字/额外字段长度可能与中央目录不同 ⇒ 以本地头为准算数据起点。
|
|
103
|
+
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) {
|
|
104
|
+
throw Object.assign(new Error('bad zip: bad local header for ' + name), { code: 'bad-zip' });
|
|
105
|
+
}
|
|
106
|
+
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
|
107
|
+
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
|
108
|
+
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
|
109
|
+
const dataEnd = dataStart + compSize;
|
|
110
|
+
if (dataEnd > buf.length) throw Object.assign(new Error('bad zip: truncated data for ' + name), { code: 'bad-zip' });
|
|
111
|
+
const raw = buf.subarray(dataStart, dataEnd);
|
|
112
|
+
|
|
113
|
+
let data;
|
|
114
|
+
if (method === 0) {
|
|
115
|
+
data = Buffer.from(raw);
|
|
116
|
+
} else if (method === 8) {
|
|
117
|
+
if (compSize > 0 && uncompSize / compSize > lim.maxRatio) {
|
|
118
|
+
throw Object.assign(new Error('suspicious compression ratio: ' + name), { code: 'too-large' });
|
|
119
|
+
}
|
|
120
|
+
try { data = inflateRawSync(raw); } catch (e) {
|
|
121
|
+
throw Object.assign(new Error('inflate failed for ' + name + ': ' + e.message), { code: 'bad-zip' });
|
|
122
|
+
}
|
|
123
|
+
if (data.length > lim.maxEntryBytes) {
|
|
124
|
+
throw Object.assign(new Error('entry too large after inflate: ' + name), { code: 'too-large' });
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
skipped.push(name + ' (method ' + method + ')');
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
entries.push({ name, data, isDir: false });
|
|
131
|
+
}
|
|
132
|
+
return { entries, skipped };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 从 URL 末尾推出一个**文件系统安全**的短名(只保留 ASCII 字母数字与连字符)。 */
|
|
136
|
+
export function safeNameFromUrl(url, fallback = 'pkg') {
|
|
137
|
+
let base = '';
|
|
138
|
+
try { base = String(new URL(url).pathname).split('/').filter(Boolean).pop() || ''; } catch { base = ''; }
|
|
139
|
+
const stem = base.replace(/\.[a-z0-9]{1,6}$/i, '');
|
|
140
|
+
const ascii = stem.replace(/[^a-zA-Z0-9-]/g, '');
|
|
141
|
+
return (ascii || fallback).slice(0, 40);
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-folia",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -158,7 +158,9 @@
|
|
|
158
158
|
"lib/components/",
|
|
159
159
|
"lib/features.js",
|
|
160
160
|
"lib/route-registry.js",
|
|
161
|
-
"lib/settings/"
|
|
161
|
+
"lib/settings/",
|
|
162
|
+
"lib/zip-read.js",
|
|
163
|
+
"lib/routes/site-package.js"
|
|
162
164
|
],
|
|
163
165
|
"dsh": {
|
|
164
166
|
"bundle": {
|