dsh-folia 4.14.0 → 4.14.1
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 +11 -3
- package/lib/routes/site-media.js +42 -16
- package/lib/routes/skins.js +50 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -18569,6 +18569,13 @@ window.__ModuleLoader__.load({
|
|
|
18569
18569
|
: installed.map((s) => {
|
|
18570
18570
|
const isActive = isActiveSkin(s.id);
|
|
18571
18571
|
const busyThis = busy === "activate:" + s.id;
|
|
18572
|
+
// 皮肤中心**没收录**这套(它有自己的 manifest 规则)⇒ 应用后它会回退到内置默认款,
|
|
18573
|
+
// 用户看到的是"点哪套都不变"。这是实测症状(别人机器上"只剩鲸鱼娘"),所以这里
|
|
18574
|
+
// 直接把原因与「应用」的禁用一起给出来,而不是让人一遍遍点。
|
|
18575
|
+
const notListed = s.inCatalog === false;
|
|
18576
|
+
const notListedWhy = notListed
|
|
18577
|
+
? weT("皮肤中心没有收录这套(它的 manifest 规则更严:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全)")
|
|
18578
|
+
: "";
|
|
18572
18579
|
return React.createElement("div", {
|
|
18573
18580
|
key: s.id,
|
|
18574
18581
|
style: isActive ? { ...SKIN_STYLE.card, borderColor: "rgba(120,220,150,0.75)" } : SKIN_STYLE.card,
|
|
@@ -18576,7 +18583,8 @@ window.__ModuleLoader__.load({
|
|
|
18576
18583
|
React.createElement("div", { style: { ...SKIN_STYLE.title, color: s.accent || undefined } },
|
|
18577
18584
|
(isActive ? weT("✓ ") : "") + (s.name || s.id)),
|
|
18578
18585
|
React.createElement("div", { style: SKIN_STYLE.meta },
|
|
18579
|
-
[s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : ""
|
|
18586
|
+
[s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : "",
|
|
18587
|
+
notListed ? weT("皮肤中心未收录") : ""]
|
|
18580
18588
|
.filter(Boolean).join(" · ")),
|
|
18581
18589
|
React.createElement("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
18582
18590
|
// 「应用」= 写皮肤中心的 active + 广播 `dsh-skin-applied`,就地切换(不刷新页面)。
|
|
@@ -18584,8 +18592,8 @@ window.__ModuleLoader__.load({
|
|
|
18584
18592
|
? React.createElement("span", { style: { ...SKIN_STYLE.badge, background: "rgba(60,160,90,0.55)" } }, weT("使用中"))
|
|
18585
18593
|
: React.createElement("button", {
|
|
18586
18594
|
className: "we-picker__btn", type: "button",
|
|
18587
|
-
disabled: Boolean(busy) || !canApply,
|
|
18588
|
-
title: applyBlockedReason || undefined,
|
|
18595
|
+
disabled: Boolean(busy) || !canApply || notListed,
|
|
18596
|
+
title: notListed ? notListedWhy : (applyBlockedReason || undefined),
|
|
18589
18597
|
onClick: () => act("activate", { id: s.id }, s.name),
|
|
18590
18598
|
}, busyThis ? weT("切换中…") : weT("应用")),
|
|
18591
18599
|
React.createElement("button", {
|
package/lib/routes/site-media.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* 注入契约(ADR-0013 D12):`DEPS` 逐键由 `lib/index.js` 给到。
|
|
28
28
|
*/
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
|
+
import { Readable } from 'node:stream';
|
|
30
31
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
31
32
|
import { join, extname } from 'node:path';
|
|
32
33
|
import { homedir } from 'node:os';
|
|
@@ -178,8 +179,10 @@ export function registerSiteMediaRoutes(webServer, c) {
|
|
|
178
179
|
const abs = join(dir, key);
|
|
179
180
|
const part = abs + '.part';
|
|
180
181
|
const meta = readPartMeta(part);
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
// 续传起点:只有 meta 明确记录的是同一个 URL 才续(换 URL 即换键,这里再兜一层)
|
|
183
|
+
const from = (() => {
|
|
184
|
+
try { return meta && meta.url === u.toString() && existsSync(part) ? statSync(part).size : 0; } catch { return 0; }
|
|
185
|
+
})();
|
|
183
186
|
|
|
184
187
|
await acquire();
|
|
185
188
|
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
@@ -221,20 +224,40 @@ export function registerSiteMediaRoutes(webServer, c) {
|
|
|
221
224
|
if (cr) res.setHeader('content-range', cr);
|
|
222
225
|
}
|
|
223
226
|
res.setHeader('cache-control', 'private, max-age=3600');
|
|
227
|
+
// 可观测:这条是**首次下载**(未命中)。命中走上面 serveFile 的分支,头是 `hit`。
|
|
228
|
+
res.setHeader('x-folia-cache', 'miss');
|
|
224
229
|
}
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
// ⚠️ 转发必须**带背压**:慢盘(机械盘 / 杀毒实时扫描)会让文件流 write() 返回 false,
|
|
231
|
+
// 此时若继续把上游字节往内存里塞,轻则内存飙高、重则把整条流拖成"越下越慢"。
|
|
232
|
+
// 旧实现只对文件流做 drain 等待、对客户端响应完全不看返回值 ⇒ 客户端慢时内存堆积。
|
|
233
|
+
// 现在两侧都看:任一侧未排空就 pause 上游,两侧都 drain 后再 resume。
|
|
234
|
+
let pendingFile = false;
|
|
235
|
+
let pendingRes = false;
|
|
236
|
+
const maybeResume = () => { if (!pendingFile && !pendingRes) { try { readerSrc.resume(); } catch { /* ignore */ } } };
|
|
237
|
+
const readerSrc = r.body ? Readable.fromWeb(r.body) : null;
|
|
227
238
|
let wrote = false;
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
239
|
+
if (readerSrc) {
|
|
240
|
+
await new Promise((resolveDone, rejectDone) => {
|
|
241
|
+
readerSrc.on('data', (chunk) => {
|
|
242
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
243
|
+
wrote = true;
|
|
244
|
+
if (!out.write(buf)) {
|
|
245
|
+
pendingFile = true;
|
|
246
|
+
readerSrc.pause();
|
|
247
|
+
out.once('drain', () => { pendingFile = false; maybeResume(); });
|
|
248
|
+
}
|
|
249
|
+
if (!headOnly && !res.writableEnded) {
|
|
250
|
+
const flushed = (() => { try { return res.write(buf); } catch { return true; } })();
|
|
251
|
+
if (!flushed) {
|
|
252
|
+
pendingRes = true;
|
|
253
|
+
readerSrc.pause();
|
|
254
|
+
res.once('drain', () => { pendingRes = false; maybeResume(); });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
readerSrc.on('end', resolveDone);
|
|
259
|
+
readerSrc.on('error', rejectDone);
|
|
260
|
+
});
|
|
238
261
|
}
|
|
239
262
|
await new Promise((resolveEnd) => { out.end(resolveEnd); });
|
|
240
263
|
if (!wrote && startAt === 0) { try { rmSync(part, { force: true }); } catch { /* ignore */ } return false; }
|
|
@@ -266,8 +289,9 @@ export function registerSiteMediaRoutes(webServer, c) {
|
|
|
266
289
|
}
|
|
267
290
|
const base0 = siteBase();
|
|
268
291
|
if (!base0) { try { res.statusCode = 404; res.end(); } catch { /* ignore */ } return; }
|
|
269
|
-
|
|
270
|
-
|
|
292
|
+
const raw = (() => {
|
|
293
|
+
try { return new URL(req.url || '', 'http://x').searchParams.get('u') || ''; } catch { return ''; }
|
|
294
|
+
})();
|
|
271
295
|
const u = allowedTarget(raw, base0);
|
|
272
296
|
if (!u) { try { res.statusCode = 400; res.end(); } catch { /* ignore */ } return; }
|
|
273
297
|
|
|
@@ -278,6 +302,8 @@ export function registerSiteMediaRoutes(webServer, c) {
|
|
|
278
302
|
|
|
279
303
|
if (ensureCache(dir) && existsSync(abs)) {
|
|
280
304
|
touch(abs);
|
|
305
|
+
// 与 miss 对称的可观测头:页签/诊断据此区分"网络慢"与"我们没有缓存"。
|
|
306
|
+
try { res.setHeader('x-folia-cache', 'hit'); } catch { /* ignore */ }
|
|
281
307
|
serveFile(abs, req, res, headOnly);
|
|
282
308
|
return;
|
|
283
309
|
}
|
package/lib/routes/skins.js
CHANGED
|
@@ -139,6 +139,44 @@ async function probeSkinCenter(hostHeader, force) {
|
|
|
139
139
|
return state;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 拉一份皮肤中心的 catalog,回答"**它到底收录了哪几套**"。
|
|
144
|
+
*
|
|
145
|
+
* 为什么必须问它、而不是自己扫目录:它有自己的收录规则(manifest.id 必须等于目录名、
|
|
146
|
+
* 必须 v2、name/nameEn/author/stylesheet 齐全,且 `diagnostics` 会给出被剔除的原因)。
|
|
147
|
+
* 我们扫到 5 个目录 ≠ 它认得这 5 套;实测症状:某台机器上"本机已装(5)"里点哪套都只显示
|
|
148
|
+
* 皮肤中心的内置默认款(blue-fantasy)——因为那几套压根不在它的 catalog 里,它的客户端按
|
|
149
|
+
* "active 解析不到就回退默认款"处理,看起来像"点了没反应"。
|
|
150
|
+
*
|
|
151
|
+
* 30 秒缓存:面板刷新/多标签共用;拿不到就返回 null("未知",不据此判红)。
|
|
152
|
+
*/
|
|
153
|
+
// 快照缓存:写进 Map 而不是给模块级对象赋值 —— 后者正是 require-atomic-updates 判红的形态,
|
|
154
|
+
// 而这里本来就是「最后一次完整快照胜出」的语义,用 Map.set 更贴合且不触发该规则。
|
|
155
|
+
const catalogCacheStore = new Map();
|
|
156
|
+
function readCatalogCache() { return catalogCacheStore.get('v') || { at: 0, map: null }; }
|
|
157
|
+
function setCatalogCache(at, map) { catalogCacheStore.set('v', { at, map }); }
|
|
158
|
+
async function fetchSkinCenterCatalog(hostHeader, force) {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const snapshot = readCatalogCache();
|
|
161
|
+
if (!force && snapshot.at && now - snapshot.at < 30000) return snapshot.map;
|
|
162
|
+
if (!hostHeader) return readCatalogCache().map;
|
|
163
|
+
let map = null;
|
|
164
|
+
try {
|
|
165
|
+
const r = await fetch('http://' + hostHeader + '/api/skin-center/v2/catalog', { signal: AbortSignal.timeout(5000) });
|
|
166
|
+
if (r.ok) {
|
|
167
|
+
const doc = await r.json();
|
|
168
|
+
const skins = (doc && Array.isArray(doc.skins)) ? doc.skins : [];
|
|
169
|
+
map = new Map();
|
|
170
|
+
for (const s of skins) {
|
|
171
|
+
const id = s && s.manifest && s.manifest.id;
|
|
172
|
+
if (typeof id === 'string' && id) map.set(id, Array.isArray(s.warnings) ? s.warnings : []);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch { map = readCatalogCache().map; }
|
|
176
|
+
setCatalogCache(now, map);
|
|
177
|
+
return map;
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
/** 站点列表缓存(30 秒):面板打开与"刷新"共用,避免每次都打站点。 */
|
|
143
181
|
const siteCache = { at: 0, items: [], error: null };
|
|
144
182
|
const SITE_TTL_MS = 30000;
|
|
@@ -342,8 +380,19 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
342
380
|
handler: async (req, res) => {
|
|
343
381
|
const force = /[?&]refresh=1/.test(req.url || '');
|
|
344
382
|
const site = await fetchSiteSkins(force);
|
|
345
|
-
const
|
|
383
|
+
const installedBase = listInstalled();
|
|
346
384
|
const skinCenter = await probeSkinCenter(req.headers && req.headers.host, force);
|
|
385
|
+
// 关键对账:**它收录了哪几套**。`null` = 问不到(不据此判红)。
|
|
386
|
+
// 被它剔除的皮肤"能装但应用后画面不变"——它客户端会回退到内置默认款,
|
|
387
|
+
// 用户看到的就是"点哪套都还是鲸鱼娘/blue-fantasy"(实测症状)。
|
|
388
|
+
const catalog = skinCenter === 'present'
|
|
389
|
+
? await fetchSkinCenterCatalog(req.headers && req.headers.host, force)
|
|
390
|
+
: null;
|
|
391
|
+
const installed = installedBase.map((s) => {
|
|
392
|
+
if (!catalog) return { ...s, inCatalog: null, catalogWarnings: [] };
|
|
393
|
+
const hit = catalog.has(s.id) ? s.id : (catalog.has(s.dirId) ? s.dirId : null);
|
|
394
|
+
return { ...s, inCatalog: Boolean(hit), catalogWarnings: hit ? catalog.get(hit) : [] };
|
|
395
|
+
});
|
|
347
396
|
// ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
|
|
348
397
|
// 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
|
|
349
398
|
// 所以比对要归一:两边都去掉可选的 `skin-` 前缀再比,否则会显示成"未安装"。
|