dsh-folia 1.6.1 → 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.
Files changed (86) hide show
  1. package/README.beginner.md +13 -13
  2. package/README.en.md +25 -25
  3. package/README.md +378 -62
  4. package/assets/about/README.md +89 -0
  5. package/assets/readme/mascot-eating-rice-1280x1784.jpg +0 -0
  6. package/cordis.patch.yml +16 -86
  7. package/lib/about/README.md +2 -2
  8. package/lib/client.js +3736 -89
  9. package/lib/components/about-notice/index.js +125 -0
  10. package/lib/components/about-notice/routes/about-qr.js +92 -0
  11. package/lib/components/about-notice/routes/github-stars.js +143 -0
  12. package/lib/components/fonts/client.build.json +22 -0
  13. package/lib/components/fonts/index.js +129 -0
  14. package/lib/components/fonts/lib/client.fonts.js +656 -0
  15. package/lib/components/fonts/routes/fontsets.js +415 -0
  16. package/lib/components/fonts/routes/system-fonts.js +530 -0
  17. package/lib/components/fonts/src/appearance-font-section.js +389 -0
  18. package/lib/components/fonts/src/client.js +82 -0
  19. package/lib/components/fonts/src/fontset-editor.js +209 -0
  20. package/lib/components/fx/client.build.json +16 -0
  21. package/lib/components/fx/index.js +44 -0
  22. package/lib/components/fx/lib/client.fx.js +847 -0
  23. package/lib/components/fx/src/client.js +75 -0
  24. package/lib/components/fx/src/ext-fx.js +155 -0
  25. package/lib/components/fx/src/fx-layer.js +635 -0
  26. package/lib/components/mascot-avatar/index.js +147 -0
  27. package/lib/components/mascot-avatar/routes/avatar.js +137 -0
  28. package/lib/components/mascot-avatar/routes/mascot.js +124 -0
  29. package/lib/components/media-bridge/audio-tap.swift +146 -0
  30. package/lib/components/media-bridge/backend.js +144 -0
  31. package/lib/components/media-bridge/index.js +129 -0
  32. package/lib/components/media-bridge/legacy.js +467 -0
  33. package/lib/components/media-bridge/provision.js +301 -0
  34. package/lib/components/media-bridge/routes/now-playing.js +177 -0
  35. package/lib/components/media-bridge/supervisor.js +765 -0
  36. package/lib/components/parallax/client.build.json +16 -0
  37. package/lib/components/parallax/index.js +44 -0
  38. package/lib/components/parallax/lib/client.parallax.js +1492 -0
  39. package/lib/components/parallax/src/client.js +60 -0
  40. package/lib/components/parallax/src/ext-parallax.js +148 -0
  41. package/lib/components/parallax/src/parallax-layer.js +1299 -0
  42. package/lib/components/pet-link/client.build.json +21 -0
  43. package/lib/components/pet-link/index.js +34 -0
  44. package/lib/components/pet-link/lib/client.pet-link.js +641 -0
  45. package/lib/components/pet-link/official-provider.js +151 -0
  46. package/lib/components/pet-link/prepared.js +10 -0
  47. package/lib/components/pet-link/routes/official-pet.js +118 -0
  48. package/lib/components/pet-link/src/client.js +55 -0
  49. package/lib/components/pet-link/src/official-pet.js +130 -0
  50. package/lib/components/pet-link/src/pet-link-actions.json +18 -0
  51. package/lib/components/pet-link/src/pet-link-timings.json +61 -0
  52. package/lib/components/pet-link/src/pet-link-tuner.js +142 -0
  53. package/lib/components/pet-link/src/pet-link.js +295 -0
  54. package/lib/diag-trace.js +1 -1
  55. package/lib/faststart.js +3 -3
  56. package/lib/host-boot.js +3 -3
  57. package/lib/index.js +51 -13
  58. package/lib/inventory.js +2 -2
  59. package/lib/kernel/component-session.js +3 -3
  60. package/lib/kernel/config.js +4 -4
  61. package/lib/kernel/data-dir.js +55 -9
  62. package/lib/kernel/host-session.js +4 -4
  63. package/lib/log.js +3 -3
  64. package/lib/media/frame-store.js +2 -2
  65. package/lib/media/media-dirs.js +1 -1
  66. package/lib/media/transcode.js +10 -10
  67. package/lib/notice.js +5 -5
  68. package/lib/routes/cache-dir.js +2 -2
  69. package/lib/routes/inventory.js +93 -33
  70. package/lib/routes/live-frame.js +1 -1
  71. package/lib/routes/media-bytes.js +1 -1
  72. package/lib/routes/occlusion.js +2 -2
  73. package/lib/routes/props.js +1 -1
  74. package/lib/routes/settings.js +3 -3
  75. package/lib/routes/we-assets.js +1 -1
  76. package/lib/serve.js +1 -1
  77. package/lib/settings-schema.js +5 -5
  78. package/lib/startup-sweep.js +1 -1
  79. package/lib/steam-discovery.js +5 -5
  80. package/lib/subpackage-boot.js +11 -11
  81. package/lib/types/client.d.ts +2 -2
  82. package/lib/types/index.d.ts +15 -15
  83. package/lib/upload-dir.js +3 -3
  84. package/lib/we-assets.js +4 -4
  85. package/lib/webwallgl/renderer.js +1 -1
  86. package/package.json +149 -22
@@ -0,0 +1,415 @@
1
+ /**
2
+ * routes/fontsets.js — **字体集族**路由:`<BASE>/fontsets` 一个前缀下的
3
+ * 七个端点 —— list / get / put / delete / activate / import / export。
4
+ *
5
+ * 为什么独立成文件:字体集是**磁盘上的一份份文件**(`fontsets/<id>.json`),而"id 从哪来、
6
+ * 谁能写进那个目录、写坏了怎么办"必须能在一处读完 —— 七条端点写进 `lib/index.js` 只会让
7
+ * 那个门面更长。
8
+ *
9
+ * 契约:`registerFontsetsRoutes(webServer, c)`;`c` 里是这一族**用到但不属于它**的东西:
10
+ * · `disposers` / `base` ← 清理句柄数组与路径前缀
11
+ * · `fontSetsDir()` ← **用户层**目录(走 `pluginDataDir()`,可写)
12
+ * · `fontSetsBuiltinDir` ← **随包层**目录(包内 `lib/fontsets/`,**只读**)
13
+ * · `readSettings()` ← 一次性迁移要读老 settings
14
+ * · `readFontSetId()` / `setFontSetId(id)` ← config.json 的**根字段**,非 settings 键集
15
+ * · `commitFontSetMigration(id)` ← 迁移落定:一次 config 写入 = 记 id + 摘掉内联字体键
16
+ * · `atomicWriteFileP` / `ensureDirOnce` ← 落地:临时文件 + rename 发布
17
+ * · `armBodyIdleTimeout` / `lingerClose` ← 请求体 idle 超时与"写应答后再断"的收尾
18
+ * · `log` ← 降级要留痕(读不懂的文件 / 迁移失败)
19
+ * · `schema` ← 共享内核的纯符号面(见下)
20
+ * · `http` ← JSON 应答 / 请求体读取的纯函数面(见下)
21
+ * 键集与消毒规则**不在这里**:它们复用共享内核 `lib/settings-schema.js` 的
22
+ * `FONTSET_KEYS` / `sanitizeFontset` / `isFontSetId` ⇒ 与 settings 同一条消毒路径,
23
+ * 且不必新建 `lib/**` 共享模块(`verify-module-layout` 的『共享内核白名单』因此不动)。
24
+ *
25
+ * ══ 两层存储(写时复制)══════════════════════════════════════════════════════
26
+ * 预设来自两层,**同一 id 时用户层胜**:
27
+ * · **随包层** `lib/fontsets/<id>.json` —— 随包发布,供"开箱就有几个能用的预设"与分享;
28
+ * · **用户层** `<pluginDataDir>/fontsets/<id>.json` —— 导入、覆盖、以及一次性迁移的产物。
29
+ * 写**永远只落用户层**:编辑一份随包预设 ⇒ 自动生成同名覆盖(写时复制),面板可标"已修改"
30
+ * 并用**删除覆盖**回到随包原样(`DELETE` 的 `restored` 字段就是这件事)。因此:
31
+ * · **包内目录永不被写**(不 `unlink`、不 `write`、不 `ensureDir`)—— 它是随包发布的字节;
32
+ * · 随包预设**删不掉**(只有覆盖可删),因为"删掉一份发布物"没有可判定的结果。
33
+ * 用户层那份**即使读不懂也照样遮住**随包层:静默回落到随包那份会让"我的改动不见了"看起来
34
+ * 像没事。宁可如实报 `broken` 并让用户删掉覆盖 —— 删了就回到随包原样,是可判定的出路。
35
+ *
36
+ * ══ 切换的扩展位(当前只做人工切换)══════════════════════════════════════════════
37
+ * `activate` 是**唯一**改活动指针的写原语(`config.json` 的根字段 `fontSetId`)。将来若要按
38
+ * 条件自动选预设(随配色 / 随壁纸类型 / 按时段),那是**策略层**,必须**调用同一个原语**落指针,
39
+ * 而不是各自再写一个"当前该用哪份"的地方 —— 指针保持单一真源,策略只决定"什么时候调它"。
40
+ *
41
+ * 不变量:
42
+ * · **id 是单段白名单**:`isFontSetId` 只放 `[A-Za-z0-9_-]{1,64}`,`. `不在表内 ⇒ `..` /
43
+ * 路径分隔符 / 绝对路径 / 盘符 / 空串**连形状都过不了**;文件名只由 `join(dir, id + '.json')`
44
+ * 构造,再叠一层目录包含性检查(分隔符无关的 `relative`)作为第二道。
45
+ * · **不落盘到白名单之外**:任何非法 id 的请求在读/写之前就被拒 ⇒ 目录内容不变。
46
+ * · **写只落用户层**:写路径经 `writeFontSet`(`userDir(c)`),绝不指向随包目录。
47
+ * · **读不懂就拒绝并说明**:`$schema` 不是当前版本的文件一律不可用(`bad-version`),
48
+ * 不猜、不静默降级成"看起来正常"的空集。
49
+ * · **写必须是原子的**:`atomicWriteFileP`(同目录 `.tmp` + rename)——半截文件会让
50
+ * 该字体集永久不可读。
51
+ * · **活动集不可删、不可被同名覆盖语义混淆**:删活动集是 400(用户得先切走),
52
+ * 因为"正在用的那份被删掉"没有可判定的正确结果。
53
+ * · **切换活动集只认读得懂的文件**:`activate` 在读不懂时拒绝(404 / 422),
54
+ * 不把用户的外观切到一个"说不清"的状态。
55
+ * · **一次性迁移是惰性的**,绝不在 `apply()` 里做:`apply` 会被**没有** `DSH_FOLIA_DATA_DIR`
56
+ * 隔离的守卫调用(`verify-scene.mjs` 就是),那时 `pluginDataDir()` 指向用户真目录 ——
57
+ * 启动期写盘会污染真机。迁移改在**第一次真的用到字体集**时发生,且由"`fontSetId` 是否
58
+ * 为空"决定,天然幂等。
59
+ * · **迁移产物一定落用户层,且不与随包 id 撞名**:随包预设一旦被用户层同名文件遮住,
60
+ * 就再也不可见了 ⇒ 随包 id 刻意避开 `default`(由守卫钉住)。
61
+ */
62
+
63
+ import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
64
+ import { isAbsolute, join, relative, resolve } from 'node:path';
65
+ // 跨包通道(ADR-0014 S5.1 / S5.3):本族**零主包 specifier** —— 共享内核的纯符号面也不静态
66
+ // import,而由 `registerFontsetsRoutes` 的 `c` 显式收下两个面(调用点 = 子包入口
67
+ // packages/fonts/lib/index.js,值来自 `ctx.wallpaperEngineHost`):
68
+ // · `c.schema` —— isFontSetId / sanitizeFontset / FONTSET_MIGRATED_ID / FONTSET_SCHEMA_TAG(纯函数 / 常量)
69
+ // · `c.http` —— sendJson / bodyReader(JSON 应答 / 请求体读取)
70
+ // 运行期才存在的**实例**(disposers / 目录 / 设置读写 / 清理句柄 …)同样逐键从 `c` 消费。
71
+ // 模块作用域**不持有**主包的任何值(ADR-0014 S5.1 / S5.3 反模式:HMR 后旧代 exports)。
72
+
73
+ /** 一份字体集正文的上限(键集 = `FONTSET_KEYS`,每键至多几十项 ⇒ 下方 `FONTSET_MAX_BYTES` 已极宽松)。 */
74
+ const FONTSET_MAX_BYTES = 256 * 1024;
75
+ /** 显示名长度上限(与设置面板一行的宽度相称)。 */
76
+ const FONTSET_NAME_MAX = 60;
77
+ /** 列表扫描上限:目录被塞爆时不让一次请求变成无界读(超出即截断,不影响取用单个集)。 */
78
+ const FONTSET_LIST_MAX = 200;
79
+
80
+ /** `fontsets/<id>.json` —— id 已过白名单,所以这是**单段**文件名。 */
81
+ function fontSetPath(dir, id) { return join(dir, id + '.json'); }
82
+
83
+ /**
84
+ * 目录包含性(第二道网,第一道是 id 白名单):规范化后必须仍在该目录内。
85
+ * 口径与 `resolveUploadFile` 一致 —— 用 `relative` 而不是拼分隔符,跨平台都成立。
86
+ */
87
+ function insideDir(dir, abs) {
88
+ const rel = relative(resolve(dir), resolve(abs));
89
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
90
+ }
91
+
92
+ /** 用户层目录(可写)/ 随包层目录(**只读**:任何写路径都不得指向它)。 */
93
+ function userDir(c) { return c.fontSetsDir(); }
94
+ function builtinDir(c) { return c.fontSetsBuiltinDir; }
95
+
96
+ /** 文件名 → id;不合白名单的(手放进去的怪名字)不进列表、也永不被写。 */
97
+ function listFontSetIds(dir, schema) {
98
+ let entries;
99
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
100
+ const ids = [];
101
+ for (const e of entries) {
102
+ if (typeof e.isFile !== 'function' || !e.isFile()) continue;
103
+ if (!e.name.endsWith('.json')) continue;
104
+ const id = e.name.slice(0, -'.json'.length);
105
+ if (!schema.isFontSetId(id)) continue;
106
+ ids.push(id);
107
+ if (ids.length >= FONTSET_LIST_MAX) break;
108
+ }
109
+ return ids.sort();
110
+ }
111
+
112
+ /**
113
+ * 读**某一层**里的字体集。返回 `{ ok: true, doc }` 或 `{ ok: false, reason }`,
114
+ * reason ∈ `missing` / `bad-json` / `bad-shape` / `bad-version` / `unsafe-path`。
115
+ */
116
+ function readFontSetAt(dir, id, schema) {
117
+ const abs = fontSetPath(dir, id);
118
+ if (!insideDir(dir, abs)) return { ok: false, reason: 'unsafe-path' };
119
+ let text;
120
+ try { text = readFileSync(abs, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
121
+ let parsed;
122
+ try { parsed = JSON.parse(text); } catch { return { ok: false, reason: 'bad-json' }; }
123
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, reason: 'bad-shape' };
124
+ if (parsed.$schema !== schema.FONTSET_SCHEMA_TAG) return { ok: false, reason: 'bad-version' };
125
+ const name = typeof parsed.name === 'string' && parsed.name.trim()
126
+ ? parsed.name.trim().slice(0, FONTSET_NAME_MAX) : id;
127
+ return { ok: true, doc: { $schema: schema.FONTSET_SCHEMA_TAG, id, name, values: schema.sanitizeFontset(parsed.values) } };
128
+ }
129
+
130
+ /**
131
+ * 按两层优先级解析一份字体集:**用户层胜**(写时复制的覆盖就住在那儿)。
132
+ * 用户层那份即使读不懂也照样遮住随包层 —— 静默回落到随包那份会让"我的改动不见了"看起来
133
+ * 像没事;如实报 `broken` 才是可判定的形态(删掉覆盖即可回到随包原样)。
134
+ */
135
+ function resolveFontSet(c, id) {
136
+ const user = readFontSetAt(userDir(c), id, c.schema);
137
+ if (user.ok) return { ok: true, doc: user.doc, origin: 'user' };
138
+ if (user.reason !== 'missing') {
139
+ return {
140
+ ok: false, reason: user.reason, origin: 'user',
141
+ shadowsBuiltin: readFontSetAt(builtinDir(c), id, c.schema).ok,
142
+ };
143
+ }
144
+ const builtin = readFontSetAt(builtinDir(c), id, c.schema);
145
+ if (builtin.ok) return { ok: true, doc: builtin.doc, origin: 'builtin' };
146
+ return { ok: false, reason: 'missing', origin: null };
147
+ }
148
+
149
+ /** 两层合并(用户层胜)+ `origin` / `overrides` / `broken` 标记,按 id 排序。 */
150
+ function listFontSets(c) {
151
+ const userIds = listFontSetIds(userDir(c), c.schema);
152
+ const builtinIds = listFontSetIds(builtinDir(c), c.schema);
153
+ const ids = [...new Set([...userIds, ...builtinIds])].sort();
154
+ return ids.slice(0, FONTSET_LIST_MAX).map((id) => {
155
+ const resolved = resolveFontSet(c, id);
156
+ const inUser = userIds.includes(id);
157
+ const row = {
158
+ id,
159
+ name: resolved.ok ? resolved.doc.name : id,
160
+ origin: resolved.ok ? resolved.origin : (inUser ? 'user' : 'builtin'),
161
+ };
162
+ if (inUser && builtinIds.includes(id)) row.overrides = true;
163
+ if (!resolved.ok) row.broken = resolved.reason;
164
+ return row;
165
+ });
166
+ }
167
+
168
+ /** 落盘(原子,**只落用户层**):正文永远由 `sanitizeFontset` 的产物构造,不照抄未信任输入。 */
169
+ async function writeFontSet(c, id, name, values) {
170
+ const dir = userDir(c);
171
+ c.ensureDirOnce(dir);
172
+ const doc = {
173
+ $schema: c.schema.FONTSET_SCHEMA_TAG,
174
+ id,
175
+ name: typeof name === 'string' && name.trim() ? name.trim().slice(0, FONTSET_NAME_MAX) : id,
176
+ values: c.schema.sanitizeFontset(values),
177
+ };
178
+ await c.atomicWriteFileP(fontSetPath(dir, id), JSON.stringify(doc, null, 2) + '\n');
179
+ return doc;
180
+ }
181
+
182
+ /**
183
+ * 一次性迁移:`config.json` 还是"老形状"(六个字体键内联在 settings 里、没有 `fontSetId`)
184
+ * ⇒ 把它们落成 `fontsets/default.json` 并记下活动 id,**同时把内联键从 settings 里摘掉**
185
+ *(`commitFontSetMigration` 一次写完 —— D1 的终态是"值只住字体集")。
186
+ * 只在**第一次真的用到字体集**时调用(见文件头的惰性理由)。
187
+ *
188
+ * ⚠️ **不覆盖已有的用户 `default`**:客户端在迁移落定前若已经改过字体,它会先 PUT 出一份
189
+ * 用户 `default`;这里若照写一遍就等于把用户刚做的编辑抹掉(而且看起来"迁移成功")。
190
+ * 已有用户文件时**直接采纳**它(`adopted: true`)。
191
+ */
192
+ async function migrateLegacyFontSet(c) {
193
+ if (c.readFontSetId()) return { migrated: false };
194
+ const existing = readFontSetAt(userDir(c), c.schema.FONTSET_MIGRATED_ID, c.schema);
195
+ if (!existing.ok) await writeFontSet(c, c.schema.FONTSET_MIGRATED_ID, '默认', c.readSettings() || {});
196
+ await c.commitFontSetMigration(c.schema.FONTSET_MIGRATED_ID);
197
+ return { migrated: true, adopted: existing.ok };
198
+ }
199
+
200
+ /** 取一个未被占用的 id(导入用):`<base>`、`<base>-2`、`<base>-3` … 到上限为止。 */
201
+ function freeFontSetId(c, wanted) {
202
+ const base = c.schema.isFontSetId(wanted) ? wanted : 'imported';
203
+ // 两层都算占用:导入撞上随包 id 会立刻变成一份"覆盖",那是用户的意图之外。
204
+ const taken = new Set([...listFontSetIds(userDir(c), c.schema), ...listFontSetIds(builtinDir(c), c.schema), 'import', 'export']);
205
+ if (!taken.has(base)) return base;
206
+ for (let i = 2; i <= FONTSET_LIST_MAX; i++) {
207
+ const id = (base + '-' + i).slice(0, 64);
208
+ if (c.schema.isFontSetId(id) && !taken.has(id)) return id;
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** 注入契约(P2-7A / ADR-0013 D12):本族从 `c` 消费的**必需**键 —— 门面/组件行调用点必须逐键给到(判据 test/verify-inject-contracts.mjs 双向对账;本族多数键经模块级 helper 的 `c` 形参消费)。 */
214
+ // 全部 13 个运行期实例(清理句柄 / 目录 / 设置读写 …)由主包 `host.keys('fonts')` 冻结供给;
215
+ // `schema` / `http` 是共享内核的纯符号面,由调用点(`packages/fonts/lib/index.js`)从
216
+ // `ctx.wallpaperEngineHost` 显式传下(见文件头)—— 本文件零主包 specifier。
217
+ export const DEPS = ['disposers', 'base', 'readFontSetId', 'fontSetsDir', 'fontSetsBuiltinDir', 'ensureDirOnce', 'atomicWriteFileP', 'readSettings', 'commitFontSetMigration', 'lingerClose', 'armBodyIdleTimeout', 'log', 'setFontSetId', 'schema', 'http'];
218
+
219
+ export function registerFontsetsRoutes(webServer, c) {
220
+ const { disposers, base: BASE, readFontSetId } = c;
221
+
222
+ /** 请求体 → JSON(带上限与 idle 超时;解析失败也算可判定的失败)。 */
223
+ function withJsonBody(req, res, onBody) {
224
+ let settled = false;
225
+ const fail = (code, payload) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ c.http.sendJson(res, code, payload);
229
+ c.lingerClose(req, res);
230
+ };
231
+ c.armBodyIdleTimeout(req, () => fail(408, { error: 'request timeout' }));
232
+ // 字节计上限 + 收完**一次性**解码(不变量见 lib/routes/upload.js 文件头):
233
+ // 逐块 `body += chunk` 会把跨 TCP 分片的多字节码点切成 U+FFFD,而字体集名 / 字体族
234
+ // 正是会被这样静默写坏的用户可见字符串。
235
+ const reader = c.http.bodyReader(req, {
236
+ maxBytes: FONTSET_MAX_BYTES,
237
+ shouldStop: () => settled,
238
+ onOverflow: () => fail(413, { error: 'fontset payload too large' }),
239
+ });
240
+ req.on('data', reader.onData);
241
+ req.on('end', () => {
242
+ if (settled) return;
243
+ settled = true;
244
+ const body = reader.text();
245
+ let parsed;
246
+ try { parsed = JSON.parse(body || '{}'); } catch {
247
+ c.http.sendJson(res, 400, { error: 'invalid JSON body' });
248
+ return;
249
+ }
250
+ onBody(parsed);
251
+ });
252
+ req.on('error', () => fail(400, { error: 'request error' }));
253
+ }
254
+
255
+ disposers.push(webServer.register({
256
+ kind: 'prefix',
257
+ path: `${BASE}/fontsets`,
258
+ handler: async (req, res) => {
259
+ const method = (req.method || 'GET').toUpperCase();
260
+ const json = (code, payload) => c.http.sendJson(res, code, payload);
261
+ // ⚠️ `new URL` 会先把 `..` / `%2e%2e` / 反斜杠形式的点段规范化掉(WHATWG 语义)
262
+ // ⇒ 穿越路径到不了这里;能到这里的都是"看起来像 id"的段,随后还要过 `isFontSetId`。
263
+ let url;
264
+ try { url = new URL(req.url || '/', 'http://x'); } catch { json(400, { error: 'invalid url' }); return; }
265
+ const rest = url.pathname.slice(`${BASE}/fontsets`.length);
266
+ if (rest.includes('//')) { json(400, { error: 'invalid path' }); return; }
267
+ let segments;
268
+ try {
269
+ segments = rest.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s));
270
+ } catch { json(400, { error: 'invalid percent-encoding in path' }); return; }
271
+ const [first, second] = segments;
272
+
273
+ // 根:GET 列表(列表前先做惰性迁移 —— 这是唯一会写盘的时机)。
274
+ if (segments.length === 0) {
275
+ if (method !== 'GET') { json(405, { error: 'method not allowed' }); return; }
276
+ let migrated = false;
277
+ let adopted = false;
278
+ try {
279
+ const r = await migrateLegacyFontSet(c);
280
+ migrated = r.migrated;
281
+ adopted = Boolean(r.adopted);
282
+ } catch (err) {
283
+ c.log.warn('fontsets: 一次性迁移失败(' + String(err && err.message ? err.message : err) + ')');
284
+ }
285
+ const active = readFontSetId();
286
+ const fontsets = listFontSets(c).map((row) => Object.assign({ active: row.id === active }, row));
287
+ json(200, { fontsets, active, migrated, adopted });
288
+ return;
289
+ }
290
+
291
+ // 导入(保留段,不允许当 id)。
292
+ if (segments.length === 1 && first === 'import') {
293
+ if (method !== 'POST') { json(405, { error: 'method not allowed' }); return; }
294
+ withJsonBody(req, res, (body) => {
295
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
296
+ json(400, { error: '字体集文件必须是一个 JSON 对象' }); return;
297
+ }
298
+ if (body.$schema !== c.schema.FONTSET_SCHEMA_TAG) {
299
+ json(400, { error: '无法读取的字体集版本(需要 ' + c.schema.FONTSET_SCHEMA_TAG + ')' }); return;
300
+ }
301
+ const id = freeFontSetId(c, body.id);
302
+ if (!id) { json(409, { error: '字体集数量已达上限' }); return; }
303
+ writeFontSet(c, id, body.name, body.values).then(
304
+ (doc) => json(200, { ok: true, id: doc.id, name: doc.name }),
305
+ (err) => json(500, { error: String(err && err.message ? err.message : err) }),
306
+ );
307
+ });
308
+ return;
309
+ }
310
+
311
+ if (segments.length === 1) {
312
+ if (!c.schema.isFontSetId(first)) { json(400, { error: 'invalid fontset id' }); return; }
313
+ if (method === 'GET') {
314
+ const read = resolveFontSet(c, first);
315
+ if (read.ok) { json(200, { ok: true, origin: read.origin, ...read.doc }); return; }
316
+ if (read.reason === 'missing') { json(404, { error: 'unknown fontset' }); return; }
317
+ c.log.warn('fontsets: 读不懂的字体集文件(' + read.reason + '):' + first);
318
+ json(422, {
319
+ error: 'fontset file is unreadable', reason: read.reason,
320
+ origin: read.origin, shadowsBuiltin: Boolean(read.shadowsBuiltin),
321
+ });
322
+ return;
323
+ }
324
+ if (method === 'PUT') {
325
+ withJsonBody(req, res, (body) => {
326
+ if (!body || typeof body !== 'object' || Array.isArray(body)
327
+ || !body.values || typeof body.values !== 'object' || Array.isArray(body.values)) {
328
+ json(400, { error: 'body 必须是 { name?, values: {…} }(values 必填:清空请显式给 {})' });
329
+ return;
330
+ }
331
+ const prev = resolveFontSet(c, first);
332
+ const name = typeof body.name === 'string' && body.name.trim()
333
+ ? body.name : (prev.ok ? prev.doc.name : first);
334
+ // 写时复制:改一份随包预设 ⇒ 落一份同名**用户层**覆盖;随包那份字节不动。
335
+ const overrides = readFontSetAt(builtinDir(c), first, c.schema).ok;
336
+ writeFontSet(c, first, name, body.values).then(
337
+ (doc) => json(200, {
338
+ ok: true, id: doc.id, name: doc.name, values: doc.values,
339
+ origin: 'user', overrides,
340
+ }),
341
+ (err) => json(500, { error: String(err && err.message ? err.message : err) }),
342
+ );
343
+ });
344
+ return;
345
+ }
346
+ if (method === 'DELETE') {
347
+ if (first === readFontSetId()) {
348
+ json(400, { error: '不能删除正在使用的字体集(先切换到别的集)' });
349
+ return;
350
+ }
351
+ // 只有**用户层**的文件可删。删掉一份覆盖 ⇒ 随包那份重新可见(= 恢复原样)。
352
+ const userAbs = fontSetPath(userDir(c), first);
353
+ const inUser = readFontSetAt(userDir(c), first, c.schema).reason !== 'missing';
354
+ if (inUser) {
355
+ try { unlinkSync(userAbs); } catch {
356
+ json(500, { error: '删除失败(文件被占用或权限不足)' });
357
+ return;
358
+ }
359
+ const restored = readFontSetAt(builtinDir(c), first, c.schema);
360
+ json(200, { ok: true, removed: first, origin: 'user', ...(restored.ok ? { restored: 'builtin' } : {}) });
361
+ return;
362
+ }
363
+ if (readFontSetAt(builtinDir(c), first, c.schema).ok) {
364
+ json(400, {
365
+ error: '随包预设不能删除(可以直接覆盖它;要恢复原样就删掉你的覆盖)',
366
+ origin: 'builtin',
367
+ });
368
+ return;
369
+ }
370
+ json(404, { error: 'unknown fontset' });
371
+ return;
372
+ }
373
+ json(405, { error: 'method not allowed' });
374
+ return;
375
+ }
376
+
377
+ // 切换活动集:`<id>/activate`。**必须**先读得懂那一份才肯切过去 ——
378
+ // 切到一个读不懂的文件等于把用户的字体外观变成"说不清",宁可拒绝。
379
+ if (segments.length === 2 && second === 'activate') {
380
+ if (method !== 'POST') { json(405, { error: 'method not allowed' }); return; }
381
+ if (!c.schema.isFontSetId(first)) { json(400, { error: 'invalid fontset id' }); return; }
382
+ const read = resolveFontSet(c, first);
383
+ if (!read.ok) {
384
+ json(read.reason === 'missing' ? 404 : 422, { error: 'fontset file is unreadable', reason: read.reason });
385
+ return;
386
+ }
387
+ c.setFontSetId(first).then(
388
+ () => json(200, { ok: true, active: first, origin: read.origin }),
389
+ (err) => json(500, { error: String(err && err.message ? err.message : err) }),
390
+ );
391
+ return;
392
+ }
393
+
394
+ // 导出:`<id>/export`(正文由读到的值**重建**,不照抄磁盘上的字节 —— 未知版本的文件
395
+ // 因此不会被导出成"看起来能分享"的东西)。
396
+ if (segments.length === 2 && second === 'export') {
397
+ if (method !== 'GET') { json(405, { error: 'method not allowed' }); return; }
398
+ if (!c.schema.isFontSetId(first)) { json(400, { error: 'invalid fontset id' }); return; }
399
+ const read = resolveFontSet(c, first);
400
+ if (!read.ok) {
401
+ json(read.reason === 'missing' ? 404 : 422, { error: 'fontset file is unreadable', reason: read.reason });
402
+ return;
403
+ }
404
+ res.statusCode = 200;
405
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
406
+ res.setHeader('Cache-Control', 'no-store');
407
+ res.setHeader('Content-Disposition', 'attachment; filename="' + first + '.json"');
408
+ res.end(JSON.stringify(read.doc, null, 2) + '\n');
409
+ return;
410
+ }
411
+
412
+ json(404, { error: 'not found' });
413
+ },
414
+ }));
415
+ }