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 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
- if (structural) setBusy(kind + ":" + ((payload && payload.id) || ""));
18363
- if (kind === "activate") setBusy("activate:" + ((payload && payload.id) || ""));
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", {
@@ -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
- let from = 0;
182
- try { from = meta && meta.url === u.toString() && existsSync(part) ? statSync(part).size : 0; } catch { from = 0; }
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
- const reader = r.body && typeof r.body.getReader === 'function' ? r.body.getReader() : null;
226
- let received = startAt;
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 (reader) {
229
- for (;;) {
230
- const { done, value } = await reader.read();
231
- if (done) break;
232
- const buf = Buffer.from(value);
233
- received += buf.length;
234
- if (!out.write(buf)) await new Promise((resolveDrain) => { out.once('drain', resolveDrain); });
235
- if (!headOnly && !res.writableEnded) { try { res.write(buf); } catch { /* 客户端走了,继续落盘 */ } }
236
- wrote = true;
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
- let raw = '';
270
- try { raw = new URL(req.url || '', 'http://x').searchParams.get('u') || ''; } catch { raw = ''; }
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
  }
@@ -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
- async function fetchSiteSkins(force) {
147
- const now = Date.now();
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 installed = listInstalled();
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.14.0",
3
+ "version": "4.14.3",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {