dsh-folia 4.14.0 → 4.14.3
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 +41 -5
- package/lib/routes/site-media.js +42 -16
- package/lib/routes/skins.js +86 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -18309,6 +18309,9 @@ window.__ModuleLoader__.load({
|
|
|
18309
18309
|
function SkinsTabPanel() {
|
|
18310
18310
|
const [state, setState] = React.useState(null);
|
|
18311
18311
|
const [busy, setBusy] = React.useState("");
|
|
18312
|
+
// 给"静默对账"用的只读镜像:定时器/可见性回调里不能读闭包里的旧 `busy`(会一直看到 ""),
|
|
18313
|
+
// 用一个 ref 跟住最新值,操作进行中就不打扰用户。
|
|
18314
|
+
const busyRef = React.useRef("");
|
|
18312
18315
|
const [note, setNote] = React.useState(null);
|
|
18313
18316
|
|
|
18314
18317
|
const load = React.useCallback(async (force) => {
|
|
@@ -18356,11 +18359,33 @@ window.__ModuleLoader__.load({
|
|
|
18356
18359
|
|
|
18357
18360
|
React.useEffect(() => { load(false); }, [load]);
|
|
18358
18361
|
|
|
18362
|
+
// 与外部改动**保持同步**:皮肤可能在皮肤中心那边被删/装,也可能在另一个窗口里操作。
|
|
18363
|
+
// 之前只在挂载时拉一次 ⇒ 那边删了,这边还列着(点了才失败,看起来像"没同步")。
|
|
18364
|
+
// 三条腿都只做"重拉一次列表",不做别的:
|
|
18365
|
+
// ① 页签重新可见(switch 回来)② 窗口重新获得焦点 ③ 每 20 秒的轻量对账
|
|
18366
|
+
// 轻量对账只在**没有正在进行的操作**时跑,避免把用户刚点的"安装中…"状态冲掉。
|
|
18367
|
+
React.useEffect(() => {
|
|
18368
|
+
if (typeof document === "undefined" || typeof window === "undefined") return undefined;
|
|
18369
|
+
let timer = null;
|
|
18370
|
+
const refreshQuietly = () => { try { if (!busyRef.current) load(false); } catch { /* ignore */ } };
|
|
18371
|
+
const onVisible = () => { try { if (document.visibilityState === "visible") refreshQuietly(); } catch { /* ignore */ } };
|
|
18372
|
+
try { document.addEventListener("visibilitychange", onVisible); } catch { /* ignore */ }
|
|
18373
|
+
try { window.addEventListener("focus", refreshQuietly); } catch { /* ignore */ }
|
|
18374
|
+
try { timer = window.setInterval(refreshQuietly, 20000); } catch { timer = null; }
|
|
18375
|
+
return () => {
|
|
18376
|
+
try { document.removeEventListener("visibilitychange", onVisible); } catch { /* ignore */ }
|
|
18377
|
+
try { window.removeEventListener("focus", refreshQuietly); } catch { /* ignore */ }
|
|
18378
|
+
if (timer !== null) { try { window.clearInterval(timer); } catch { /* ignore */ } }
|
|
18379
|
+
};
|
|
18380
|
+
}, [load]);
|
|
18381
|
+
|
|
18359
18382
|
const act = async (kind, payload, label) => {
|
|
18360
18383
|
// 结构性操作才锁整块 UI;`activate` 是幂等的、而且要让人能连点换皮肤,所以不锁。
|
|
18361
18384
|
const structural = kind === "install" || kind === "remove";
|
|
18362
|
-
|
|
18363
|
-
|
|
18385
|
+
const busyKey = structural
|
|
18386
|
+
? kind + ":" + ((payload && payload.id) || "")
|
|
18387
|
+
: (kind === "activate" ? "activate:" + ((payload && payload.id) || "") : "");
|
|
18388
|
+
if (busyKey) { setBusy(busyKey); busyRef.current = busyKey; }
|
|
18364
18389
|
setNote(null);
|
|
18365
18390
|
try {
|
|
18366
18391
|
if (kind === "activate") {
|
|
@@ -18454,6 +18479,7 @@ window.__ModuleLoader__.load({
|
|
|
18454
18479
|
setNote({ ok: false, text: weT("请求失败:{msg}", { msg: (e && e.message) || weT("网络错误") }) });
|
|
18455
18480
|
} finally {
|
|
18456
18481
|
setBusy("");
|
|
18482
|
+
busyRef.current = "";
|
|
18457
18483
|
}
|
|
18458
18484
|
};
|
|
18459
18485
|
|
|
@@ -18569,6 +18595,15 @@ window.__ModuleLoader__.load({
|
|
|
18569
18595
|
: installed.map((s) => {
|
|
18570
18596
|
const isActive = isActiveSkin(s.id);
|
|
18571
18597
|
const busyThis = busy === "activate:" + s.id;
|
|
18598
|
+
// 皮肤中心**没收录**这套(它有自己的 manifest 规则)⇒ 应用后它会回退到内置默认款,
|
|
18599
|
+
// 用户看到的是"点哪套都不变"。这是实测症状(别人机器上"只剩鲸鱼娘"),所以这里
|
|
18600
|
+
// 直接把原因与「应用」的禁用一起给出来,而不是让人一遍遍点。
|
|
18601
|
+
// ⚠️ 只有 `=== false` 才下结论:`null` 表示"问不到它的 catalog"(未知),
|
|
18602
|
+
// 未知绝不能禁用按钮 —— 那会变成"替我猜错了就不让我点"。
|
|
18603
|
+
const notListed = s.inCatalog === false;
|
|
18604
|
+
const notListedWhy = notListed
|
|
18605
|
+
? weT("皮肤中心仍未收录这套(它按 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全)。点「刷新」会强制它对账一次;若仍显示这条,说明确实被它拒收")
|
|
18606
|
+
: "";
|
|
18572
18607
|
return React.createElement("div", {
|
|
18573
18608
|
key: s.id,
|
|
18574
18609
|
style: isActive ? { ...SKIN_STYLE.card, borderColor: "rgba(120,220,150,0.75)" } : SKIN_STYLE.card,
|
|
@@ -18576,7 +18611,8 @@ window.__ModuleLoader__.load({
|
|
|
18576
18611
|
React.createElement("div", { style: { ...SKIN_STYLE.title, color: s.accent || undefined } },
|
|
18577
18612
|
(isActive ? weT("✓ ") : "") + (s.name || s.id)),
|
|
18578
18613
|
React.createElement("div", { style: SKIN_STYLE.meta },
|
|
18579
|
-
[s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : ""
|
|
18614
|
+
[s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : "",
|
|
18615
|
+
notListed ? weT("皮肤中心未收录") : ""]
|
|
18580
18616
|
.filter(Boolean).join(" · ")),
|
|
18581
18617
|
React.createElement("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
18582
18618
|
// 「应用」= 写皮肤中心的 active + 广播 `dsh-skin-applied`,就地切换(不刷新页面)。
|
|
@@ -18584,8 +18620,8 @@ window.__ModuleLoader__.load({
|
|
|
18584
18620
|
? React.createElement("span", { style: { ...SKIN_STYLE.badge, background: "rgba(60,160,90,0.55)" } }, weT("使用中"))
|
|
18585
18621
|
: React.createElement("button", {
|
|
18586
18622
|
className: "we-picker__btn", type: "button",
|
|
18587
|
-
disabled: Boolean(busy) || !canApply,
|
|
18588
|
-
title: applyBlockedReason || undefined,
|
|
18623
|
+
disabled: Boolean(busy) || !canApply || notListed,
|
|
18624
|
+
title: notListed ? notListedWhy : (applyBlockedReason || undefined),
|
|
18589
18625
|
onClick: () => act("activate", { id: s.id }, s.name),
|
|
18590
18626
|
}, busyThis ? weT("切换中…") : weT("应用")),
|
|
18591
18627
|
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,12 +139,66 @@ 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;
|
|
145
183
|
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
/**
|
|
185
|
+
* 给"本机已装"逐条附上**皮肤中心是否收录**。
|
|
186
|
+
*
|
|
187
|
+
* 比对口径要宽:它的 catalog 以 `manifest.id` 为键,而我们的条目同时有 `id`(manifest)与
|
|
188
|
+
* `dirId`(目录名),且历史上站点 slug 可能带 `skin-` 前缀 ⇒ 三种形态都试,任一命中即算收录。
|
|
189
|
+
* `catalog === null`(问不到)时给 `null`:**未知不等于未收录**,界面不能据此禁用「应用」。
|
|
190
|
+
*/
|
|
191
|
+
function annotateCatalog(installed, catalog) {
|
|
192
|
+
if (!catalog) return installed.map((s) => ({ ...s, inCatalog: null, catalogWarnings: [] }));
|
|
193
|
+
return installed.map((s) => {
|
|
194
|
+
const hit = catalog.has(s.id) ? s.id
|
|
195
|
+
: catalog.has(s.dirId) ? s.dirId
|
|
196
|
+
: catalog.has(normSkinId(s.id)) ? normSkinId(s.id) : null;
|
|
197
|
+
return { ...s, inCatalog: Boolean(hit), catalogWarnings: hit ? catalog.get(hit) : [] };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function fetchSiteSkins(force) { const now = Date.now();
|
|
148
202
|
if (!force && siteCache.at && now - siteCache.at < SITE_TTL_MS) return siteCache;
|
|
149
203
|
const base = siteBase();
|
|
150
204
|
if (!base) { siteCache.at = now; siteCache.items = []; siteCache.error = 'site-off'; return siteCache; }
|
|
@@ -342,8 +396,23 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
342
396
|
handler: async (req, res) => {
|
|
343
397
|
const force = /[?&]refresh=1/.test(req.url || '');
|
|
344
398
|
const site = await fetchSiteSkins(force);
|
|
345
|
-
const
|
|
399
|
+
const installedBase = listInstalled();
|
|
346
400
|
const skinCenter = await probeSkinCenter(req.headers && req.headers.host, force);
|
|
401
|
+
// 关键对账:**它收录了哪几套**。`null` = 问不到(不据此判红)。
|
|
402
|
+
// 被它剔除的皮肤"能装但应用后画面不变"——它客户端会回退到内置默认款,
|
|
403
|
+
// 用户看到的就是"点哪套都还是鲸鱼娘/blue-fantasy"(实测症状)。
|
|
404
|
+
//
|
|
405
|
+
// ⚠️ 但"它没列出"**不能**只凭一次缓存就下定论:刚装完皮肤时,它自己的 catalog 可能还没
|
|
406
|
+
// 重扫(它按目录 mtime 做缓存),我们 30 秒的副本更可能比它更旧。实测:别人机器上刚装完
|
|
407
|
+
// 就被标成「皮肤中心未收录」,而打开它的设置页却是有的 —— 一次假红。
|
|
408
|
+
// 所以对账分两拍:先按缓存比,**出现"未收录"就强制重拉一次**再定论。
|
|
409
|
+
const hostHeader = req.headers && req.headers.host;
|
|
410
|
+
let catalog = skinCenter === 'present' ? await fetchSkinCenterCatalog(hostHeader, force) : null;
|
|
411
|
+
let installed = annotateCatalog(installedBase, catalog);
|
|
412
|
+
if (catalog && installed.some((s) => s.inCatalog === false)) {
|
|
413
|
+
catalog = await fetchSkinCenterCatalog(hostHeader, true);
|
|
414
|
+
installed = annotateCatalog(installedBase, catalog);
|
|
415
|
+
}
|
|
347
416
|
// ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
|
|
348
417
|
// 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
|
|
349
418
|
// 所以比对要归一:两边都去掉可选的 `skin-` 前缀再比,否则会显示成"未安装"。
|
|
@@ -475,11 +544,24 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
475
544
|
const dir = resolveInside(root, id);
|
|
476
545
|
if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
|
|
477
546
|
if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
|
|
547
|
+
// 删之前先看它是不是"当前正在用的那套":如果是,必须**先把选择清掉**再删目录,
|
|
548
|
+
// 否则 active 指向一个不存在的 id ⇒ 皮肤中心回退到内置默认款,用户看到的是
|
|
549
|
+
// "皮肤没换成我选的那套"(实测症状)。清空走同一套兜底链路(HTTP→状态文件)。
|
|
550
|
+
let clearedActive = false;
|
|
551
|
+
try {
|
|
552
|
+
const statePath = activeStatePath();
|
|
553
|
+
let cur = null;
|
|
554
|
+
try { cur = (JSON.parse(readFileSync(statePath, 'utf8')) || {}).active || null; } catch { cur = null; }
|
|
555
|
+
if (cur && (cur === id || normSkinId(cur) === normSkinId(id))) {
|
|
556
|
+
writeActiveStateFile(null);
|
|
557
|
+
clearedActive = true;
|
|
558
|
+
}
|
|
559
|
+
} catch { /* 清不掉也不阻塞删除:目录照删,只是 active 可能悬空(页签会显示"恢复默认外观") */ }
|
|
478
560
|
try { rmSync(dir, { recursive: true, force: true }); } catch (e) {
|
|
479
561
|
sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(e && e.message).slice(0, 160) });
|
|
480
562
|
return;
|
|
481
563
|
}
|
|
482
|
-
sendJson(res, 200, { ok: true, id });
|
|
564
|
+
sendJson(res, 200, { ok: true, id, clearedActive });
|
|
483
565
|
},
|
|
484
566
|
}));
|
|
485
567
|
|