dsh-folia 1.6.0 → 2.0.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/README.beginner.md +13 -13
- package/README.en.md +25 -25
- package/README.md +378 -62
- package/assets/about/README.md +89 -0
- package/assets/readme/mascot-eating-rice-1280x1784.jpg +0 -0
- package/cordis.patch.yml +16 -86
- package/lib/about/README.md +2 -2
- package/lib/client.js +3736 -89
- package/lib/components/about-notice/index.js +125 -0
- package/lib/components/about-notice/routes/about-qr.js +92 -0
- package/lib/components/about-notice/routes/github-stars.js +143 -0
- package/lib/components/fonts/client.build.json +22 -0
- package/lib/components/fonts/index.js +129 -0
- package/lib/components/fonts/lib/client.fonts.js +656 -0
- package/lib/components/fonts/routes/fontsets.js +415 -0
- package/lib/components/fonts/routes/system-fonts.js +530 -0
- package/lib/components/fonts/src/appearance-font-section.js +389 -0
- package/lib/components/fonts/src/client.js +82 -0
- package/lib/components/fonts/src/fontset-editor.js +209 -0
- package/lib/components/fx/client.build.json +16 -0
- package/lib/components/fx/index.js +44 -0
- package/lib/components/fx/lib/client.fx.js +847 -0
- package/lib/components/fx/src/client.js +75 -0
- package/lib/components/fx/src/ext-fx.js +155 -0
- package/lib/components/fx/src/fx-layer.js +635 -0
- package/lib/components/mascot-avatar/index.js +147 -0
- package/lib/components/mascot-avatar/routes/avatar.js +137 -0
- package/lib/components/mascot-avatar/routes/mascot.js +124 -0
- package/lib/components/media-bridge/audio-tap.swift +146 -0
- package/lib/components/media-bridge/backend.js +144 -0
- package/lib/components/media-bridge/index.js +129 -0
- package/lib/components/media-bridge/legacy.js +467 -0
- package/lib/components/media-bridge/provision.js +301 -0
- package/lib/components/media-bridge/routes/now-playing.js +177 -0
- package/lib/components/media-bridge/supervisor.js +765 -0
- package/lib/components/parallax/client.build.json +16 -0
- package/lib/components/parallax/index.js +44 -0
- package/lib/components/parallax/lib/client.parallax.js +1492 -0
- package/lib/components/parallax/src/client.js +60 -0
- package/lib/components/parallax/src/ext-parallax.js +148 -0
- package/lib/components/parallax/src/parallax-layer.js +1299 -0
- package/lib/components/pet-link/client.build.json +21 -0
- package/lib/components/pet-link/index.js +34 -0
- package/lib/components/pet-link/lib/client.pet-link.js +641 -0
- package/lib/components/pet-link/official-provider.js +151 -0
- package/lib/components/pet-link/prepared.js +10 -0
- package/lib/components/pet-link/routes/official-pet.js +118 -0
- package/lib/components/pet-link/src/client.js +55 -0
- package/lib/components/pet-link/src/official-pet.js +130 -0
- package/lib/components/pet-link/src/pet-link-actions.json +18 -0
- package/lib/components/pet-link/src/pet-link-timings.json +61 -0
- package/lib/components/pet-link/src/pet-link-tuner.js +142 -0
- package/lib/components/pet-link/src/pet-link.js +295 -0
- package/lib/diag-trace.js +1 -1
- package/lib/faststart.js +3 -3
- package/lib/host-boot.js +3 -3
- package/lib/index.js +51 -13
- package/lib/inventory.js +2 -2
- package/lib/kernel/component-session.js +3 -3
- package/lib/kernel/config.js +4 -4
- package/lib/kernel/data-dir.js +55 -9
- package/lib/kernel/host-session.js +4 -4
- package/lib/log.js +3 -3
- package/lib/media/frame-store.js +2 -2
- package/lib/media/media-dirs.js +1 -1
- package/lib/media/transcode.js +10 -10
- package/lib/notice.js +5 -5
- package/lib/routes/cache-dir.js +2 -2
- package/lib/routes/inventory.js +93 -33
- package/lib/routes/live-frame.js +1 -1
- package/lib/routes/media-bytes.js +1 -1
- package/lib/routes/occlusion.js +2 -2
- package/lib/routes/props.js +1 -1
- package/lib/routes/settings.js +3 -3
- package/lib/routes/we-assets.js +1 -1
- package/lib/serve.js +1 -1
- package/lib/settings-schema.js +5 -5
- package/lib/startup-sweep.js +1 -1
- package/lib/steam-discovery.js +5 -5
- package/lib/subpackage-boot.js +11 -11
- package/lib/types/client.d.ts +2 -2
- package/lib/types/index.d.ts +15 -15
- package/lib/upload-dir.js +3 -3
- package/lib/we-assets.js +4 -4
- package/lib/webwallgl/renderer.js +1 -1
- package/package.json +149 -22
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `about-notice` 子包宿主入口(P-F 第一格:`lib/components/about-notice/` → `packages/about-notice/`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这一格先出:它那两条路由与主包其余路由**没有共享可变状态**(规模由
|
|
5
|
+
* `node test/tools/host-route-index.mjs` 现算),出户成本最低,而收益是宿主可以把
|
|
6
|
+
* `cordis.patch.yml` 里这一行单独 disable(其余各行一个不动)。
|
|
7
|
+
*
|
|
8
|
+
* 两处加载路径共用同一份实现(照抄 dsh-ops 的组件真形):
|
|
9
|
+
* ① 宿主把 patch 行的 `name` 当**独立 plugin** 加载 ⇒ `apply(ctx, config)` 是入口;
|
|
10
|
+
* ② 门面的 `applyComponent(root, rawConfig, 'about-notice')` 是"按组件加载"入口。
|
|
11
|
+
* P-F 之后两条路径的 deps **不再是两份各写各的字面量**:门面与入口都从
|
|
12
|
+
* `lib/subpackage-boot.js` 的 `subpackageKeys('about-notice', ctx)` 取**同一份冻结键视图**
|
|
13
|
+
* ⇒ 两条路径不可能漂移(对账面 = `test/verify-inject-contracts.mjs` ② 的叶子冻结表逐键对账)。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **跨包纪律(ADR-0014 S5.1 / S5.2,判据 `test/verify-subpackage-purity.mjs`)**:本模块
|
|
16
|
+
* **零主包说明符、零相对路径逃出本包** —— 只 import 本包内的路由族与 `node:` 内建。宿主能力一律
|
|
17
|
+
* 经 `ctx.wallpaperEngineHost` 的键视图(`host.keys('about-notice', ctx)`)取。这不是风格问题:
|
|
18
|
+
* 相对 import 主包文件、或 import 主包名,都是该判据的硬红。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **取服务姿势**:`inject` 必须先声明(`webServer` + `wallpaperEngineHost`)再读
|
|
21
|
+
* `ctx.<服务>`。cordis 的 reflect `get` 对「未声明 inject 就取的服务属性」当场抛
|
|
22
|
+
* `cannot get property "webServer" without inject at applyImpl`(真机原文),随后的早退分支
|
|
23
|
+
* 永远执行不到(死代码),两条装载路径双双失败、该组件的路由全 404。声明后由 Loader 保证
|
|
24
|
+
* 「服务就绪才激活」,`applyImpl` 里的早退只兜「服务在、但路由表还没挂上」这一种情况。
|
|
25
|
+
* 对应判据:`test/verify-bundle-rows.mjs` ⑧(入口读的每个 `ctx.<服务>` 都必须在其 inject 里)。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **teardown 得交两份(关掉再打开子组件会撞 `duplicate … route`)**:本模块导出的 `apply`
|
|
28
|
+
* 是**函数声明**(有 `prototype`)⇒ cordis 的 loader 走 `new apply(ctx, config)` 分支、
|
|
29
|
+
* **返回值被丢弃** ⇒ 只 `return keys.runComponent(…)` 的话 fiber 卸载时没人撤路由(旧注册留在
|
|
30
|
+
* 宿主路由表里),下一次启用立刻撞「重复路由」。所以返回值一律走 `keys.handOffTeardown(ctx, …)`:
|
|
31
|
+
* 同一份 disposer 也交给 `ctx.effect`(cordis 的显式清理通道)并做幂等包装。见 `lib/host-boot.js`。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ **模块作用域纪律(P2-1b 定,P-F 后仍适用)**:模块作用域(顶层、任何函数体之外)只许放
|
|
34
|
+
* 声明/常量 —— 不得在模块求值期去读别的模块的绑定并调用它(那一刻对方可能还没求值完,抛
|
|
35
|
+
* `ReferenceError: Cannot access '…' before initialization`,P2-1b NC1 实测报错原文)。
|
|
36
|
+
*/
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
// 本包内的两条路由族(`lib/routes/*.js` 是路由族的**唯一**落点:落在别处 `docs/ROUTE-INDEX.md`
|
|
39
|
+
// 会静默少行,见 test/tools/host-route-index.mjs 的候选面)。
|
|
40
|
+
import { registerGithubStarsRoutes } from './routes/github-stars.js';
|
|
41
|
+
import { registerAboutQrRoutes } from './routes/about-qr.js';
|
|
42
|
+
|
|
43
|
+
/** patch 行 slug(`cordis.patch.yml` 里该行 `name` 的完整值,两处逐字相等)。 */
|
|
44
|
+
export const name = 'dsh-plugin-folia-about-notice';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 硬依赖 web 服务 + 主包宿主服务:与主行 `lib/index.js` 的 `inject` 同一套语义。
|
|
48
|
+
* 组件行走 patch 行加载时,Loader 靠这两句保证「服务就绪才激活」,`ctx.webServer` /
|
|
49
|
+
* `ctx.wallpaperEngineHost` 才读得到。
|
|
50
|
+
*/
|
|
51
|
+
export const inject = ['webServer', 'wallpaperEngineHost'];
|
|
52
|
+
|
|
53
|
+
/** 独立 plugin 入口:宿主按 patch 行加载本子包时走这里,转调本模块的实现(不经门面)。 */
|
|
54
|
+
export function apply(ctx, config) {
|
|
55
|
+
// ⚠️ 这个字面形态是**承重**的:`test/verify-inject-contracts.mjs` 的 `tableBindingNames()` 按
|
|
56
|
+
// `const <host> = ctx.<服务名>` 认「本文件里的冻结表绑定」(`recvRe`),再据此把下面调用点的
|
|
57
|
+
// `...keys` 展开成叶子冻结表。写成 `ctx && ctx.<服务名>` 守卫就认不出表 ⇒ 整包调用点判 dynamic
|
|
58
|
+
// 而红(nc-sub7 钉的就是这条:接收者必须源自 `ctx.<服务名>`)。
|
|
59
|
+
const host = ctx.wallpaperEngineHost;
|
|
60
|
+
// 拿不到服务名就大声失败:静默 `undefined` 会让下面每一步都在 undefined 上取属性(半死态)。
|
|
61
|
+
if (!host) {
|
|
62
|
+
throw new Error('[folia] about-notice 没拿到 wallpaperEngineHost 服务'
|
|
63
|
+
+ '(inject 声明与宿主装载顺序不匹配)');
|
|
64
|
+
}
|
|
65
|
+
// 键视图 = `lib/subpackage-boot.js` 的 SUBPACKAGE_KEY_TABLE 里本包那一行(值全部来自主包模块级绑定)。
|
|
66
|
+
// 记账 / 错误留证 / 「失败仍原样抛」三件事都在 `keys.runComponent()`(门面 `applyComponent`
|
|
67
|
+
// 走的是同一个函数)⇒ 两条装载路径共用一份语义,不可能一条记了、另一条忘了。
|
|
68
|
+
const keys = host.keys('about-notice', ctx);
|
|
69
|
+
// `http` 面(`sendJson` / `bodyReader`)不在冻结键表里,与 `packages/fonts` 同款:按需显式带上。
|
|
70
|
+
const deps = Object.freeze({ ...keys, http: host.http });
|
|
71
|
+
return keys.handOffTeardown(ctx, keys.runComponent('about-notice', ctx, config, deps, applyImpl));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 组件实现。第三个形参名取 `c`(与 `lib/kernel/host-session.js` 同一约定):
|
|
76
|
+
* `test/tools/host-route-index.mjs --deps` 按 `const { … } = c;` 现抽契约;路由族调用点的
|
|
77
|
+
* `...keys` 展开则由 `test/verify-inject-contracts.mjs` 按冻结表认(`keys` 与 `apply()` 里那个
|
|
78
|
+
* 绑定同名 ⇒ 两边指的是同一份键视图)。
|
|
79
|
+
*/
|
|
80
|
+
export function applyImpl(root, _rawConfig, c) {
|
|
81
|
+
const keys = c;
|
|
82
|
+
const { base, pluginName, repoSlugFromPkg, pluginDataDir, componentViewFrom, createComponentSession } = c;
|
|
83
|
+
// 两条装载路径都能这么读:① 组件行自己声明了 `inject = ['webServer', 'wallpaperEngineHost']`;② 门面路径的 `root`
|
|
84
|
+
// 就是门面的 ctx,而门面同样声明了 `inject = ['webServer']`(主行 `lib/index.js` 的 `inject` 声明)。
|
|
85
|
+
const webServer = root && root.webServer;
|
|
86
|
+
// 与门面 `apply()` 同款早退:服务在、但 register 面还没挂上(或换成了别的 webServer 实现)。
|
|
87
|
+
if (!webServer || typeof webServer.register !== 'function') return () => {};
|
|
88
|
+
|
|
89
|
+
// P2-7B 注入面收窄:从内核取**本组件声明的视图**(`COMPONENT_VIEW_KEYS['about-notice']`),
|
|
90
|
+
// 不再从整个 `hostKernel` 现解构 —— 后者等于把整个内核(键数与分级由
|
|
91
|
+
// `test/verify-component-face.mjs` 现算)当成注入面(M9 的现状)。视图缺键即抛,不静默 `undefined`。
|
|
92
|
+
const view = componentViewFrom('about-notice');
|
|
93
|
+
// 迷你会话(disposers / activeStreams / mediaMap / log / serveFile / 同序 teardown)收在内核侧;
|
|
94
|
+
// serve 套件的入参(`SESSION_SERVE_KEYS`:appendDiagLine / traceRequests / traceMediaRequests /
|
|
95
|
+
// readWebShim / buildSeedScript)由会话从视图取 ⇒ 它们不再出现在组件入口的解构面里。
|
|
96
|
+
const { log, disposers, serveFile, teardown } = createComponentSession(root, { base, pluginName }, view);
|
|
97
|
+
const { ABOUT_DIR } = view;
|
|
98
|
+
|
|
99
|
+
// ⚠️ **注册期必须包 try**(P2-4):本实现要注册**两条**路由族,第 N 条抛错时前面 N-1 条已经
|
|
100
|
+
// 把 disposer 推进了 `disposers`。若不在这里收尾就往外抛,`teardown` 就**永远不会交回宿主**
|
|
101
|
+
// (`runComponent()` 只在 impl 正常返回时拿到它)⇒ 已注册的路由留在宿主路由表里,下一次启用
|
|
102
|
+
// 立刻撞 `webserver: duplicate … route`。收尾失败不掩盖原始错误:teardown 自己吞异常,
|
|
103
|
+
// 原错照抛(装载失败必须仍然是红的)。
|
|
104
|
+
try {
|
|
105
|
+
// 「关于」页签的仓库 star 数 → lib/routes/github-stars.js。**只读**一条腿:本插件唯一一处
|
|
106
|
+
// 出站请求(api.github.com),带 10 分钟 TTL + 落盘缓存兜底;一键 star 不做(GitHub 要点星
|
|
107
|
+
// 必须有用户凭据,插件不存任何 token)。
|
|
108
|
+
registerGithubStarsRoutes(webServer, {
|
|
109
|
+
...keys, disposers, base, repoSlug: repoSlugFromPkg(),
|
|
110
|
+
cachePath: () => join(pluginDataDir(), 'star-count.json'),
|
|
111
|
+
log, http: keys.http,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 「关于」页签的两张联系方式二维码(静态 PNG,白名单直出)→ lib/routes/about-qr.js。
|
|
115
|
+
registerAboutQrRoutes(webServer, { ...keys, disposers, base, aboutDir: ABOUT_DIR, serveFile });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
teardown();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 本组件自己的收尾(会话的 teardown,见 lib/kernel/component-session.js):与 host-session 的
|
|
122
|
+
// teardown 同序(逐个 disposer → destroy 在途流 → 清两个容器),但**没有** releaseKernelLease
|
|
123
|
+
// —— 见文件头那条有意不对称。
|
|
124
|
+
return teardown;
|
|
125
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/about-notice/lib/routes/about-qr.js — 「关于」页签那两张联系方式二维码 + 「更新公告」配图的**静态资源路由**:
|
|
3
|
+
* `GET/HEAD <BASE>/about-qr/<文件名>`(`lib/about/` 下的 PNG/JPEG,白名单里没有的一律 404)。
|
|
4
|
+
* 公告配图(`update-notice.jpg`)与二维码同走这一族:同为随包静态图、同为"会换"的图
|
|
5
|
+
* (群号换了就得换码、公告配图随版本换图),URL 段沿用 `/about-qr/` 前缀 —— 本文件头注释
|
|
6
|
+
* 就是口径,改名反而会 churn 两处稳定 URL。
|
|
7
|
+
*
|
|
8
|
+
* 为什么单独成文件:这是全插件**唯一一处"把随包文件按 URL 直出"**的资源族(其余静态面要么是
|
|
9
|
+
* 用户数据 `/media`,要么是渲染页 `/scene-live`),而"URL 段怎么变成路径"必须能在一处读完 ——
|
|
10
|
+
* 这段代码离用户输入最近。
|
|
11
|
+
*
|
|
12
|
+
* 契约:`registerAboutQrRoutes(webServer, c)`;`c` 里是这一族用到但不属于它的东西:
|
|
13
|
+
* · `disposers` / `base: BASE` ← 清理句柄与路径前缀
|
|
14
|
+
* · `aboutDir` ← `lib/about/` 的绝对路径(包内资源,只读)
|
|
15
|
+
* · `serveFile` ← 出字节的统一出口(mime / Range / 条件 GET 都在它手里)
|
|
16
|
+
*
|
|
17
|
+
* 不变量(每条都有守卫):
|
|
18
|
+
* · **路径只能来自白名单**:URL 段**不做**任何拼接 —— 先用 `decodeURIComponent` 取到名字,
|
|
19
|
+
* 再在 `aboutQrFile(name)` 里**查表**换路径。没进表的名字连 `join()` 都不会被调用,
|
|
20
|
+
* 于是 `..`、绝对路径、NUL、编码穿越在结构上就不可能落到白名单之外(第二道是
|
|
21
|
+
* `serveFile` 前的存在性判断)。
|
|
22
|
+
* · **只认 GET/HEAD**:其余方法 405(本路由没有写面)。
|
|
23
|
+
* · **304 由 serveFile 负责**:`revalidate` 让应答带 ETag(`size-mtime`) + `no-cache`,
|
|
24
|
+
* 换掉 `lib/about/` 里的 PNG 后客户端**立刻**拿到新图(这是一张会变的图:群号换了就得换码)。
|
|
25
|
+
* · **错误响应不带缓存**(`serveFile` 的既有口径):Electron 缓存过 404 之后即使文件到位
|
|
26
|
+
* 也会一直显示旧的错误页。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 白名单:URL 段 → 文件名。**唯一**把请求映射到磁盘的地方。
|
|
33
|
+
* 名字用 `String` 的精确比较(`===`),不看前缀/后缀 —— 于是 `qq-group.png.bak`、
|
|
34
|
+
* `./qq-group.png`、`qq-group.png/` 之类都过不了。
|
|
35
|
+
*/
|
|
36
|
+
const ABOUT_QR_FILES = ['qq-group.png', 'douyin-group.png', 'update-notice.jpg'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 命中白名单返回绝对路径(含目录包含性复查),否则 null。
|
|
40
|
+
* ⚠️ **不导出**:路由族模块(`lib/routes/*.js` 或组件目录里的实现文件)里每个导出函数都会被
|
|
41
|
+
* 路由索引当成一个"注册入口"去解析 context 契约(多导一个普通函数会让那条判据误红)——
|
|
42
|
+
* 这条规则由 `verify-route-index` 钉住。
|
|
43
|
+
* 要测它就走路由(穿越 / 编码 / 未登记名字一律 404)。
|
|
44
|
+
*/
|
|
45
|
+
function aboutQrFile(name, aboutDir) {
|
|
46
|
+
const hit = ABOUT_QR_FILES.find((f) => f === String(name));
|
|
47
|
+
if (!hit) return null;
|
|
48
|
+
const dir = resolve(aboutDir);
|
|
49
|
+
const abs = resolve(dir, hit);
|
|
50
|
+
// 第二道(对将来的白名单维护者):文件名必须是该目录的**直接子项**。
|
|
51
|
+
// 现在这张表里不可能有 `..`,但表是人维护的 —— 判据不依赖"人不犯错"。
|
|
52
|
+
//
|
|
53
|
+
// ⚠️ 判据必须**分隔符无关**:`relative()`,不是 `abs.startsWith(dir + '/')`。
|
|
54
|
+
// **实测(CI windows-latest)**:前缀写法在 Windows 上必然判 null ——
|
|
55
|
+
// `resolve()` 产出的是反斜杠路径,而前缀拼的是正斜杠 ⇒ 连白名单命中的图都 404
|
|
56
|
+
// (本机 macOS 全绿、CI 红,症状就是这个)。
|
|
57
|
+
// 这条判据与 lib/routes/fontsets.js 的 `insideDir`、lib/upload-store.js 的 `resolveUploadFile`
|
|
58
|
+
// **逐字同一条**。
|
|
59
|
+
const rel = relative(dir, abs);
|
|
60
|
+
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? abs : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 注入契约(P2-7A / ADR-0013 D12):本族从 `c` 消费的**必需**键 —— 门面/组件行调用点必须逐键给到(判据 test/verify-inject-contracts.mjs 双向对账)。 */
|
|
64
|
+
export const DEPS = ['disposers', 'base', 'aboutDir', 'serveFile'];
|
|
65
|
+
|
|
66
|
+
export function registerAboutQrRoutes(webServer, c) {
|
|
67
|
+
const { disposers, base: BASE, aboutDir, serveFile } = c;
|
|
68
|
+
|
|
69
|
+
disposers.push(webServer.register({
|
|
70
|
+
kind: 'prefix',
|
|
71
|
+
path: `${BASE}/about-qr`,
|
|
72
|
+
handler: (req, res) => {
|
|
73
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
74
|
+
if (method !== 'GET' && method !== 'HEAD') { res.statusCode = 405; res.end(); return; }
|
|
75
|
+
const headOnly = method === 'HEAD';
|
|
76
|
+
// `new URL` 会先把 `..` / `%2e%2e` / 反斜杠形式的点段规范化掉(WHATWG 语义),
|
|
77
|
+
// 能走到这里的只剩"看起来像文件名"的段;随后还要过白名单。
|
|
78
|
+
let url;
|
|
79
|
+
try { url = new URL(req.url || '/', 'http://x'); } catch { res.statusCode = 400; res.end('bad url'); return; }
|
|
80
|
+
const rest = url.pathname.slice(`${BASE}/about-qr`.length).replace(/^\/+/, '');
|
|
81
|
+
if (!rest || rest.includes('/')) { res.statusCode = 404; res.end('not found'); return; }
|
|
82
|
+
let name;
|
|
83
|
+
try { name = decodeURIComponent(rest); } catch { res.statusCode = 400; res.end('bad encoding'); return; }
|
|
84
|
+
const abs = aboutQrFile(name, aboutDir);
|
|
85
|
+
if (!abs) { res.statusCode = 404; res.setHeader('Cache-Control', 'no-store'); res.end('not found'); return; }
|
|
86
|
+
// revalidate:尺寸/时间未变 ⇒ 304 无体;变了 ⇒ 立刻送新图(这些都是"会换"的图:
|
|
87
|
+
// 群号换了就得换码,公告配图随版本换)。serveFile 按**扩展名**给 mime —— 混排
|
|
88
|
+
// PNG/JPEG 不需要这里做任何事。
|
|
89
|
+
serveFile(abs, req, res, headOnly, { revalidate: true });
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/about-notice/lib/routes/github-stars.js — **仓库 star 数**的唯一一条路由:`GET <BASE>/star-count`
|
|
3
|
+
* (「关于」页签那行「⭐ 当前 N star」的数据源)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独成文件:**这是本插件唯一一条出站请求**(其余部分全是本机数据)。"什么时候可以
|
|
6
|
+
* 联网 / 拉不到怎么办 / 缓存放哪 / 会不会被限流"必须能在一处读完 —— 写进 `lib/index.js` 只会
|
|
7
|
+
* 让那个门面再多一段与它无关的联网逻辑。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不做"一键 star":GitHub 不允许未认证的程序点星(`PUT /user/starred/…` 要用户凭据)。
|
|
10
|
+
* 插件**不存任何 token**、也不借用本机 `gh` 的凭据 ⇒ 这里只有**只读**一条腿,点星由用户
|
|
11
|
+
* 在仓库页完成(关于页给的是链接 + 可复制地址)。
|
|
12
|
+
*
|
|
13
|
+
* 契约:`registerGithubStarsRoutes(webServer, c)`;`c` 里是这一族用到但不属于它的东西:
|
|
14
|
+
* · `disposers` / `base: BASE` ← 清理句柄与路径前缀
|
|
15
|
+
* · `repoSlug` ← 'owner/repo'(`lib/host-boot.js` 从 package.json 现读,
|
|
16
|
+
* 见那里的 `repoSlugFromPkg` —— 仓库地址只有一处真源)
|
|
17
|
+
* · `cachePath()` ← 落盘缓存路径(`pluginDataDir()/star-count.json`)
|
|
18
|
+
* · `fetchJson(url, ms)` ← **可注入**的出站请求(守卫喂替身,避免测试联网)
|
|
19
|
+
* · `log` ← 失败留痕(成功不记:成功是常态,不该刷屏)
|
|
20
|
+
*
|
|
21
|
+
* 不变量(每条都有守卫):
|
|
22
|
+
* · **只读**:只 GET `api.github.com/repos/<slug>`,永不发写请求、永不带凭据。
|
|
23
|
+
* · **绝不把失败当错误甩给用户**:网络失败 / 非 2xx / 限流都回 200 + `ok:false` + 原因;
|
|
24
|
+
* 有落盘缓存时回它并标 `stale:true` —— 关于页显示"上一次取到的值",不是断网图标。
|
|
25
|
+
* · **不放大请求**:TTL(10 分钟)内直接回内存缓存;并发请求合并成一次出站(GitHub 未认证
|
|
26
|
+
* 限流是 **60 次/小时/IP**,且**整机共享** —— 别的程序也在用这个额度)。
|
|
27
|
+
* · **失败要重试、成功才计 TTL**:失败不写 `at`(下次一问就再试),只有成功才进入冷却。
|
|
28
|
+
* · **超时必须收**:出站 8s 超时(`AbortSignal.timeout`),不能让一个挂住的连接占住 handler。
|
|
29
|
+
* · **落盘必须原子**:同目录 `.tmp` + rename(半截 JSON 会让"离线兜底"这条腿永久失效)。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
33
|
+
|
|
34
|
+
/** 成功结果的新鲜期:10 分钟内不再出站(客户端侧同一量级,两处相乘 ≈ 最坏 20 分钟一次)。 */
|
|
35
|
+
const STAR_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
/** 出站超时:GitHub 正常在 1s 内应答,8s 足够,也不会把设置页的一次打开拖长。 */
|
|
37
|
+
const STAR_FETCH_TIMEOUT_MS = 8000;
|
|
38
|
+
|
|
39
|
+
/** 默认出站实现:只读 GET,带 UA(GitHub API 要求有 UA),带超时。 */
|
|
40
|
+
async function defaultFetchJson(url, ms) {
|
|
41
|
+
const res = await fetch(url, {
|
|
42
|
+
redirect: 'follow',
|
|
43
|
+
signal: AbortSignal.timeout(ms),
|
|
44
|
+
headers: {
|
|
45
|
+
'User-Agent': 'dsh-folia',
|
|
46
|
+
Accept: 'application/vnd.github+json',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error('HTTP ' + res.status);
|
|
51
|
+
}
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 从 API 应答里取 star 数:形状不对就当失败(宁可显示"取不到",也不要显示一个假的数)。 */
|
|
56
|
+
function starsFromApi(json) {
|
|
57
|
+
const n = json && json.stargazers_count;
|
|
58
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 注入契约(P2-7A / ADR-0013 D12):本族从 `c` 消费的**必需**键 —— 门面/组件行调用点必须逐键给到(判据 test/verify-inject-contracts.mjs 双向对账)。
|
|
62
|
+
* `http` 是主包 json-response / http-body 的**唯一出口**(P-F:本族不再相对 import 主包文件)。 */
|
|
63
|
+
export const DEPS = ['disposers', 'base', 'repoSlug', 'cachePath', 'log', 'http'];
|
|
64
|
+
/** 允许缺席的键:真实调用点(组件行 about-notice)不传,缺省实现是下面的 `defaultFetchJson`。 */
|
|
65
|
+
export const OPTIONAL_DEPS = ['fetchJson'];
|
|
66
|
+
|
|
67
|
+
export function registerGithubStarsRoutes(webServer, c) {
|
|
68
|
+
const { disposers, base: BASE, repoSlug, cachePath, log, http } = c;
|
|
69
|
+
const fetchJson = typeof c.fetchJson === 'function' ? c.fetchJson : defaultFetchJson;
|
|
70
|
+
|
|
71
|
+
/** 内存缓存 { count, at } —— 进程内共享,重启后由落盘那份接上。 */
|
|
72
|
+
let mem = null;
|
|
73
|
+
/** 在途请求:并发合并(多个窗口同时打开设置页也只出站一次)。 */
|
|
74
|
+
let inflight = null;
|
|
75
|
+
|
|
76
|
+
const fresh = (v) => Boolean(v) && Number.isFinite(v.count) && Date.now() - Number(v.at || 0) < STAR_TTL_MS;
|
|
77
|
+
|
|
78
|
+
function readDisk() {
|
|
79
|
+
try {
|
|
80
|
+
const o = JSON.parse(readFileSync(cachePath(), 'utf8'));
|
|
81
|
+
if (o && Number.isFinite(o.count) && Number.isFinite(o.at)) return { count: o.count, at: o.at };
|
|
82
|
+
} catch { /* 没有 / 读不懂都当"没有" —— 缓存不是真源 */ }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeDisk(v) {
|
|
87
|
+
try {
|
|
88
|
+
const file = cachePath();
|
|
89
|
+
const tmp = file + '.tmp';
|
|
90
|
+
writeFileSync(tmp, JSON.stringify({ count: v.count, at: v.at }));
|
|
91
|
+
renameSync(tmp, file);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// 写不进缓存不影响这次应答;留个痕就够(下次仍能联网取到)。
|
|
94
|
+
try { log('star-count 缓存写入失败:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchOnce() {
|
|
99
|
+
const url = 'https://api.github.com/repos/' + repoSlug;
|
|
100
|
+
const json = await fetchJson(url, STAR_FETCH_TIMEOUT_MS);
|
|
101
|
+
const count = starsFromApi(json);
|
|
102
|
+
if (count === null) throw new Error('unexpected payload');
|
|
103
|
+
const got = { count, at: Date.now() };
|
|
104
|
+
mem = got;
|
|
105
|
+
writeDisk(got);
|
|
106
|
+
return got;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
disposers.push(webServer.register({
|
|
110
|
+
kind: 'exact',
|
|
111
|
+
path: `${BASE}/star-count`,
|
|
112
|
+
handler: async (req, res) => {
|
|
113
|
+
// 薄别名:保留调用点写法(只有 200 + body),实现收敛到主包的 lib/json-response.js(经
|
|
114
|
+
// 宿主服务的 `http` 面注入 —— P-F 起本包不再相对 import 主包文件)。
|
|
115
|
+
// 缓存由**本路由**管(TTL 在上游),别让浏览器/CDN 再叠一层不可控的缓存 ⇒ no-store。
|
|
116
|
+
const reply = (body) => http.sendJson(res, 200, body);
|
|
117
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') { res.statusCode = 405; res.end(); return; }
|
|
118
|
+
// 没有可解析的仓库地址(package.json 被改坏 / 不是从包安装):功能关闭,但**不报错** ——
|
|
119
|
+
// 关于页只是不显示数字,其余照常。
|
|
120
|
+
if (!repoSlug) { reply({ ok: false, count: null, error: 'no-repo' }); return; }
|
|
121
|
+
|
|
122
|
+
const cached = mem || readDisk();
|
|
123
|
+
if (fresh(cached)) {
|
|
124
|
+
mem = cached;
|
|
125
|
+
reply({ ok: true, count: cached.count, fetchedAt: cached.at, stale: false });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!inflight) {
|
|
130
|
+
inflight = fetchOnce().finally(() => { inflight = null; });
|
|
131
|
+
}
|
|
132
|
+
const got = await inflight;
|
|
133
|
+
reply({ ok: true, count: got.count, fetchedAt: got.at, stale: false });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const stale = cached || readDisk();
|
|
136
|
+
try { log('star-count 拉取失败:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
|
|
137
|
+
// 有旧值就回旧值(标 stale):关于页显示"上一次取到的数"比显示"取不到"有用。
|
|
138
|
+
if (stale) reply({ ok: true, count: stale.count, fetchedAt: stale.at, stale: true });
|
|
139
|
+
else reply({ ok: false, count: null, error: String((err && err.message) || err) });
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"entry": "src/client.js",
|
|
3
|
+
"artifact": "lib/client.fonts.js",
|
|
4
|
+
"inline": [
|
|
5
|
+
{
|
|
6
|
+
"file": "src/appearance-font-section.js",
|
|
7
|
+
"why": "外观页签第六节「全局字体」的渲染器(原 src/panel-tabs-appearance.js:54-420,逐字搬来);入口的 registerPanelSection 把它的 render 登记进面板节注册表",
|
|
8
|
+
"markers": [
|
|
9
|
+
"function familyOptions(sel, opts) {",
|
|
10
|
+
"function renderAppearanceFontSection(ctx) {"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"file": "src/fontset-editor.js",
|
|
15
|
+
"why": "「字体集」编辑器面板(原 src/fontset-editor.js,整文件逐字搬来):纯渲染 + 意图回调,由字体节的 renderFontSet(fontSet) 调用",
|
|
16
|
+
"markers": [
|
|
17
|
+
"function deleteLabel(row) {",
|
|
18
|
+
"function renderFontSetEditor(ctx) {"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js — **字体系统子包的宿主入口**。
|
|
3
|
+
*
|
|
4
|
+
* 落点 = 字体子包的**宿主半边**(ADR-0014 S3.4):
|
|
5
|
+
* · 搬进来的:字体系统的**两条宿主路由族** —— `fontsets`(字体集 /fontsets)与
|
|
6
|
+
* `system-fonts`(本机字体清单 /system-fonts),见 ./routes/*.js。
|
|
7
|
+
* · P-C(2026-10-16)之后**浏览器半边也随包走**:入口 `src/client.js` + 外观页签第六节
|
|
8
|
+
* 「全局字体」渲染器 `src/appearance-font-section.js` + 字体集编辑器 `src/fontset-editor.js`,
|
|
9
|
+
* 产物 `lib/client.fonts.js` ⇒ 本包**声明** `dsh.client` 且清单里有 `exports['./client']`
|
|
10
|
+
* (ADR-0014 S3.4 的两向判据)。
|
|
11
|
+
* · **仍然不搬**:`src/fontset-store.js`、`src/system-fonts.js`、`src/font/apply.js`、
|
|
12
|
+
* `src/font/typography.js`、`src/font/color-roles.js`、`src/font/components.js`
|
|
13
|
+
* (它们与主包其它半边在求值期/运行期双向耦合,见 §6.1 = A 的归属裁决)。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 归属(务必先读):用户口径 **§6.1 = A「代码归属,不是状态归属」**(正本 = ADR-0013 **D20**)。
|
|
16
|
+
* 字体键的**真源与状态**在主包 `lib/settings-schema.js`(`FONTSET_KEYS` 单点定义)与
|
|
17
|
+
* `fontsets/<id>.json`;本包裹走的只是**渲染器与描述符** ⇒ 本包**不持键、不持久化、
|
|
18
|
+
* 不注册 `settings.section`**,读值一律经主包构造的渲染 ctx(或本文件声明的 `host` 面)。
|
|
19
|
+
*
|
|
20
|
+
* 两条加载路径共用这一个实现(与 packages/about-notice/lib/index.js 同形):
|
|
21
|
+
* ① 宿主按 patch 行(cordis.patch.yml)当独立 plugin 加载 ⇒ `apply(ctx, config)`(拆出后**唯一**
|
|
22
|
+
* 的加载路径:主包门面已不再有字体路由的调用点)。
|
|
23
|
+
* ② 两条路由族自己的 `registerXxxRoutes(webServer, c)` 也可以被别的调用点直接驱动 —— `c` 由本文件拼齐。
|
|
24
|
+
*
|
|
25
|
+
* ══ 跨包通道(唯一一条)══════════════════════════════════════════════════════════════
|
|
26
|
+
* ADR-0014 S5.1 / S5.3:子包宿主半边**不 import 主包任何模块**
|
|
27
|
+
* (相对路径 import 与主包裸 specifier 都禁止)。本包的 `inject` 声明主包供给的 Cordis 服务
|
|
28
|
+
* `wallpaperEngineHost`,`apply()` 里 `const host = ctx.wallpaperEngineHost` 取到全部面:
|
|
29
|
+
* · `host.keys('fonts', ctx)` —— **冻结的显式键集**(本文件只消费它,不自己拼内核);
|
|
30
|
+
* 缺 id / 缺键一律**抛错**,不许静默 undefined(ADR-0013 D3③「宁可显示、不可静默消失」的宿主侧纪律)。
|
|
31
|
+
* · `host.keyNames('fonts')` —— 声明面(守卫读的那份;本文件拿它当 REQUIRED_KEYS 的对账面)。
|
|
32
|
+
* · `host.handOff(ctx, dispose)` —— 与 lib/host-boot.js 同名函数同义:`export function apply`
|
|
33
|
+
* 是**函数声明**(有 prototype)⇒ cordis 走 `new apply(ctx)`、**返回值被丢弃** ⇒ 只 `return dispose`
|
|
34
|
+
* 不够,必须同时交 `ctx.effect`(同 lib/index.js 的 `apply()` 末尾 fiber disposer 教训 / lib/host-boot.js 的 `handOffTeardown`)。
|
|
35
|
+
* · `host.schema` / `host.http` —— 共享内核的纯符号与 JSON 应答 / 读体的两个纯函数面;
|
|
36
|
+
* 两条路由族不再静态 import 主包,由本文件显式传给它们的 register(见 routes/*.js 文件头)。
|
|
37
|
+
*
|
|
38
|
+
* 为什么不是「解构整个 hostKernel」/ 不是叶子 import:`import` 是**模块图**上的边,关掉主包后子包
|
|
39
|
+
* 仍握着旧代 exports(ADR-0014 S5.1 / S5.3 反模式:留着指向主包的模块图边);`inject` 才是 cordis 唯一的级联判据
|
|
40
|
+
* (`name in fiber.inject`)⇒ 主包停用,子包 fiber 自动 park(ADR-0014 S7.2「关主包 = 关整族」的物理基础)。
|
|
41
|
+
*
|
|
42
|
+
* 开发期解析:`scripts/dev-link-subpackages.mjs` 在 `<profile>/node_modules/` 建
|
|
43
|
+
* `dsh-plugin-folia-<pkg> → packages/<pkg>` junction(发布态天然是兄弟目录,同一份 patch 两态成立)。
|
|
44
|
+
* ⚠️ 本文件已零主包 specifier ⇒ 仓内跑它**不再**需要仓库根的 `node_modules/dsh-plugin-folia`
|
|
45
|
+
* 自链接(那一层链接只服务旧的 `dsh-plugin-folia/subpackage-boot.js` 叶子通道)。
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { join } from 'node:path';
|
|
49
|
+
|
|
50
|
+
import { registerFontsetsRoutes } from './routes/fontsets.js';
|
|
51
|
+
import { registerSystemFontsRoutes } from './routes/system-fonts.js';
|
|
52
|
+
|
|
53
|
+
/** 行标识:与 packages/fonts/cordis.patch.yml 里那行的 `name` 逐字相同(ADR-0014 S4.2 会逐字对账)。 */
|
|
54
|
+
export const name = 'dsh-plugin-folia-fonts';
|
|
55
|
+
|
|
56
|
+
/** 这一族的宿主注入面:`webServer`(两条路由都注册在它上面)+ 主包的跨包服务面(ADR-0014 S5.1)。 */
|
|
57
|
+
export const inject = ['webServer', 'wallpaperEngineHost'];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 宿主半边入口。`ctx` 是 cordis 的插件上下文(`new apply(ctx)` 形态),`config` 来自 patch 行的 config
|
|
61
|
+
* (本包今天不使用:路由路径/键名都是定死的,要求行为零回归)。
|
|
62
|
+
*
|
|
63
|
+
* @returns {() => void} 拆卸函数(同时已交 `ctx.effect`;mock ctx 无 effect 时退回返回值,见 host-boot.js:188-197)。
|
|
64
|
+
*/
|
|
65
|
+
export function apply(ctx, _config) {
|
|
66
|
+
const webServer = ctx && ctx.webServer;
|
|
67
|
+
// provider 缺席(主包没起 / 没有 webServer):与 about-notice 同口径 —— 静默退回空拆卸,
|
|
68
|
+
// 不抛异常炸掉整行(行级失败静默)。
|
|
69
|
+
if (!webServer || typeof webServer.register !== 'function') return () => {};
|
|
70
|
+
|
|
71
|
+
// 跨包服务面:键集 / 声明面 / 拆卸交接 / 纯符号面都从这**一个**服务取(子包半边零主包说明符)。
|
|
72
|
+
const host = ctx.wallpaperEngineHost;
|
|
73
|
+
// 显式键集:键表 **逐字派生**自主包服务面的冻结表(真源 lib/subpackage-boot.js),缺 id / 缺键在这里抛错
|
|
74
|
+
// (**不静默 undefined**)。键集只含 host-only 的**运行期实例**;共享内核的纯符号面走 host.schema /
|
|
75
|
+
// host.http,由下方传给两条路由的 register。
|
|
76
|
+
const keys = host.keys('fonts', ctx);
|
|
77
|
+
assertFontsKeys(host.keyNames('fonts'), keys);
|
|
78
|
+
|
|
79
|
+
// 拆卸句柄数组:照主包 lib/kernel/host-session.js:190/:257 的口径(一个数组、插入顺序、
|
|
80
|
+
// 逐条兜错),两条路由族的 register 都往它 push。
|
|
81
|
+
const disposers = [];
|
|
82
|
+
// 两个调用点各自给**自己那一族**的键集:`...keys` 是冻结表(唯一真源),`disposers` 是共用
|
|
83
|
+
// 拆卸句柄数组,`cachePath` 只给 system-fonts 一族(fontsets 不消费它 —— 别把不认识自己的键塞给它,
|
|
84
|
+
// test/verify-inject-contracts.mjs 的调用点对账会把它记成「多给」);
|
|
85
|
+
// `schema` / `http` 是两条族共用的**纯符号面**(来自 host),显式传给各自的 register。
|
|
86
|
+
try {
|
|
87
|
+
registerFontsetsRoutes(webServer, { ...keys, disposers, schema: host.schema, http: host.http });
|
|
88
|
+
registerSystemFontsRoutes(webServer, {
|
|
89
|
+
...keys,
|
|
90
|
+
disposers,
|
|
91
|
+
schema: host.schema,
|
|
92
|
+
http: host.http,
|
|
93
|
+
// 落盘缓存路径与主包同式(本文件 :94;出户前 = 主包 lib/index.js:383):pluginDataDir()/system-fonts.json。
|
|
94
|
+
cachePath: () => join(keys.pluginDataDir(), 'system-fonts.json'),
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// 半边起不来就整行回滚(不留半死态:第一条已经 push 的 disposer 先跑掉再抛)。
|
|
98
|
+
disposeAll(disposers);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return host.handOff(ctx, () => disposeAll(disposers));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 本包从 `c` 消费的全部键。**不手写第二份清单**:逐字派生自主包服务面的冻结表
|
|
107
|
+
* (`host.keyNames('fonts')`)—— 冻结表是唯一真源(守卫读的声明面),派生保证两份永不漂移。
|
|
108
|
+
* `disposers`/`cachePath`/`schema`/`http` 由本文件自带,不在冻结表内。
|
|
109
|
+
*
|
|
110
|
+
* ADR-0013 D3③「宁可显示、不可静默消失」的宿主侧对应纪律:键集**缺一个就抛错**,绝不让 `undefined` 顺着
|
|
111
|
+
* `c` 流进路由(那时错误会变成"某个请求 500"或"某个下拉空着",离根因很远)。
|
|
112
|
+
* 声明面与实际键集不一致 = 契约漂移,必须在这里炸而不是靠人记得。
|
|
113
|
+
*/
|
|
114
|
+
function assertFontsKeys(required, keys) {
|
|
115
|
+
const missing = required.filter((k) => keys[k] === undefined);
|
|
116
|
+
if (missing.length) {
|
|
117
|
+
throw new Error(`[folia-fonts] host.keys('fonts') 缺少声明面要求的键:${missing.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 逐条兜错的拆卸:**插入顺序**、单条抛错不影响其余(与 lib/kernel/host-session.js:257 逐字同口径)。
|
|
123
|
+
* `splice(0)` 让重复调用成 no-op(teardown 可能被 ctx.effect + 调用方各跑一次)。
|
|
124
|
+
*/
|
|
125
|
+
function disposeAll(disposers) {
|
|
126
|
+
for (const d of disposers.splice(0)) {
|
|
127
|
+
try { d(); } catch { /* ignore */ }
|
|
128
|
+
}
|
|
129
|
+
}
|