modelstage 0.0.0-stage → 0.5.28

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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,286 @@
1
+ // server/plugins/slot-catalog.mjs —— 插槽目录(唯一权威)+ 声明/注册两套校验
2
+ //
3
+ // 为什么目录要做成**代码里的常量表**(而不是 JSON 文件):
4
+ // ① 与 PLUGIN_ROLES 同一个做法(registry.mjs:16),插槽名字与风险等级是**契约事实**,
5
+ // 不是部署配置 —— 放 JSON 会让"改一个名字"变成运维动作;
6
+ // ② 校验逻辑与目录必须同源:分成两个文件就会出现"目录里加了、校验没跟上"的漂移。
7
+ //
8
+ // 两个校验入口,对应两个不同时机,**不要混用**:
9
+ // validateDeclaredSlots() —— 清单校验期(读 plugin.json 时):校验"你声明了什么"
10
+ // validateSlotRegistration() —— 注册期(插件调 slots.register 时):校验"这一次注册"
11
+ // 区别只有一条:③ undeclared-slot 只有注册期才判得出来(清单里的每一项按定义都是"已声明")。
12
+ //
13
+ // 判定顺序**固定不可调换**(设计 §4.2):unknown-slot → slot-not-open → undeclared-slot → risk-mismatch。
14
+ // 为什么 slot-not-open 必须排在 risk 之前:toolbar.actions 在目录里是 append、但它属于未开放阶段;
15
+ // 先判 risk 的话会报"risk 不符" —— 可它写得完全正确,只是阶段没到,这种错误会把人引向错方向。
16
+
17
+ /** 阶段序列:用来比较"目录声明的 stage 是否已到" */
18
+ /**
19
+ * 阶段序列(**只能用这六档**)。
20
+ *
21
+ * S6 是**占位档**,不是"第六阶段":它表示"尚未排期"。
22
+ * 为什么要它:`stage.overlay` 设计里写明"暂不排期",但若把它留在 S4,
23
+ * 一旦 CURRENT_STAGE 抬到 S5(S4 < S5),它就会被判成"已开放" ——
24
+ * 而它根本没有渲染实现,结果是"注册成功但看不见",比直接拒绝更难查。
25
+ * 用 S6 表达"排在所有已实现阶段之后",既让判定天然为假,又不必给目录多一个维度。
26
+ */
27
+ export const SLOT_STAGES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6'];
28
+
29
+ /** 当前实现阶段。改动这里等于"开放下一批插槽",是**有意为之的开关**,不要让它随代码漂移 */
30
+ export const CURRENT_STAGE = 'S5';
31
+
32
+ /** 风险三档(设计 §三):append 只追加 / replace 会替换原生内容 / shadow 会遮蔽原生界面 */
33
+ export const SLOT_RISKS = ['append', 'replace', 'shadow'];
34
+
35
+ /** 插槽类型(设计 §三):single 单值 / list 列表 / keyed 键值 */
36
+ export const SLOT_TYPES = ['single', 'list', 'keyed'];
37
+
38
+ /**
39
+ * 插槽目录(第一版全表)。
40
+ *
41
+ * 只有这五项 —— 加减插槽必须改这里,且**只改这里**(校验与 /api/plugins 下发都读本表)。
42
+ * name 里的 `<xxx>` 是**占位符**:匹配"该前缀 + 一段非空名字",不是精确名。
43
+ */
44
+ export const SLOT_CATALOG = [
45
+ // 单值:后注册覆盖先注册。会替换原生品牌名 ⇒ replace,且留到 S5(品牌是产品级的东西)
46
+ { name: 'panel.brand', type: 'single', risk: 'replace', stage: 'S5',
47
+ desc: '右栏品牌名/副标题' },
48
+ // 列表:往指定分组追加控件。只追加不动原生 ⇒ append(默认允许),S2 开放
49
+ { name: 'panel.group.<groupId>', type: 'list', risk: 'append', stage: 'S2',
50
+ desc: '往右栏某个分组里追加控件(groupId 由插件定,建议带 plugin. 前缀)' },
51
+ // 键值:按控件 id 占位、可整体替换该控件 ⇒ replace,S5
52
+ { name: 'panel.control.<key>', type: 'keyed', risk: 'replace', stage: 'S5',
53
+ desc: '替换某个内置控件(key 必须写成 plugin.<pluginId>.<key>,见设计 §8.2)' },
54
+ // 工具栏按钮:只追加 ⇒ append,但属于第二步(S3)
55
+ { name: 'toolbar.actions', type: 'list', risk: 'append', stage: 'S3',
56
+ desc: '顶栏追加按钮' },
57
+ // 舞台叠加层:只追加 ⇒ append,暂不排期(S4)
58
+ // S6 = 尚未排期(占位档,不是"第六阶段"):留 S4 的话,CURRENT_STAGE 抬到 S5 时会被判成已开放,
59
+ // 而它没有渲染实现 —— 那种"注册成功但看不见"比直接拒绝难查得多。
60
+ { name: 'stage.overlay', type: 'list', risk: 'append', stage: 'S6',
61
+ desc: '画布之上的叠加层(调试/水印)—— 尚未排期,S6 为占位档' },
62
+ ];
63
+
64
+ /** 转义正则元字符(占位符名字里可能有 `.`,必须转义成字面点) */
65
+ function escapeRe(text) {
66
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+
69
+ /**
70
+ * 在目录里找一条插槽:先精确匹配,再按占位符规则匹配。
71
+ *
72
+ * 顺序很重要:`panel.brand` 是精确项,若先跑占位符规则也能被 `panel.<x>` 命中(当前目录没有这种写法,
73
+ * 但将来加了宽泛规则就会出问题)⇒ 精确优先。
74
+ * @param {string} name 插槽名(如 `panel.brand` / `panel.group.设置`)
75
+ * @returns {object|null} 目录项;找不到返回 null
76
+ */
77
+ export function matchSlot(name) {
78
+ const want = String(name || '').trim();
79
+ if (!want) return null;
80
+ const exact = SLOT_CATALOG.find((s) => s.name === want);
81
+ if (exact) return exact;
82
+ for (const entry of SLOT_CATALOG) {
83
+ const open = entry.name.indexOf('<');
84
+ if (open < 0) continue; // 精确项上面已经处理过
85
+ const head = entry.name.slice(0, open); // 如 'panel.group.'
86
+ // 占位符吃"一段非空名字",**允许带点**:设计 §8.2 要求 panel.control.<key> 的 key 写成
87
+ // `plugin.<pluginId>.<key>`(本身含两个点),若这里禁点,那条规范就永远匹配不上。
88
+ // 转义层级注意:模板串里写 `[^\\s]`,产出的正则才是"非空白";写成 `[^\\\\s]` 会变成
89
+ // "不是反斜杠且不是字母 s" —— 任何名字含 s 的插槽(settings / skin)都会被误判 unknown-slot。
90
+ if (new RegExp(`^${escapeRe(head)}[^\\s]+$`).test(want)) return entry;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * 目录项的阶段是否已开放(stage 序号 <= 当前阶段序号)。
97
+ *
98
+ * `currentStage` 可注入:生产调用一律用缺省(CURRENT_STAGE),只有测试需要"假装已经到 S5"
99
+ * 才能覆盖 risk-mismatch 这类**只有在插槽已开放时才会走到**的分支
100
+ * (当前第一版目录里最靠前的是 S2,若不允许注入,那些分支就永远测不到)。
101
+ * @param {object} entry 目录项
102
+ * @param {string} [currentStage] 当前阶段(缺省 CURRENT_STAGE)
103
+ */
104
+ export function isSlotOpen(entry, currentStage = CURRENT_STAGE) {
105
+ if (!entry) return false;
106
+ const at = SLOT_STAGES.indexOf(entry.stage);
107
+ const cur = SLOT_STAGES.indexOf(String(currentStage));
108
+ if (at < 0 || cur < 0) return false; // 写错的 stage 一律按"未开放"处理(保守)
109
+ return at <= cur;
110
+ }
111
+
112
+ /**
113
+ * 校验**一次注册请求**(设计 §4.2 的四步,顺序固定)。
114
+ *
115
+ * @param {string} name 插件要注册的插槽名
116
+ * @param {string[]} declaredSlots 该插件在清单里声明的插槽名(已通过 validateDeclaredSlots)
117
+ * @param {object} [opts] { risk } 插件在这次注册里声明的风险等级(可选;缺省按目录)
118
+ * @returns {{ok: true, entry: object} | {ok: false, error: object}}
119
+ */
120
+ export function validateSlotRegistration(name, declaredSlots, opts = {}) {
121
+ const want = String(name || '').trim();
122
+ const declared = Array.isArray(declaredSlots) ? declaredSlots.map(String) : [];
123
+ const currentStage = opts.currentStage === undefined ? CURRENT_STAGE : String(opts.currentStage);
124
+
125
+ // ① 目录里有没有这个插槽
126
+ const entry = matchSlot(want);
127
+ if (!entry) {
128
+ return { ok: false, error: { name: want, why: 'unknown-slot', hint: '插槽目录里没有这一项(见 /api/plugins 的 slotCatalog)' } };
129
+ }
130
+ // ② 阶段开放了没有(必须早于 risk:见文件头注释)
131
+ if (!isSlotOpen(entry, currentStage)) {
132
+ return {
133
+ ok: false,
134
+ error: {
135
+ name: want, why: 'slot-not-open',
136
+ catalogStage: entry.stage,
137
+ hint: `该插槽尚未开放(阶段 ${entry.stage}+)`,
138
+ },
139
+ };
140
+ }
141
+ // ③ 清单里声明了没有(只有注册期判得出来)
142
+ if (!declared.includes(want)) {
143
+ return {
144
+ ok: false,
145
+ error: {
146
+ name: want, why: 'undeclared-slot',
147
+ hint: `清单没声明这个插槽;要先在 plugin.json 的 slots 里列出 "${want}"`,
148
+ },
149
+ };
150
+ }
151
+ // ④ risk 与目录是否一致
152
+ const declaredRisk = opts.risk === undefined || opts.risk === null ? entry.risk : String(opts.risk);
153
+ if (declaredRisk !== entry.risk) {
154
+ return {
155
+ ok: false,
156
+ error: {
157
+ name: want, why: 'risk-mismatch',
158
+ catalogRisk: entry.risk,
159
+ declaredRisk,
160
+ hint: `目录要求 ${entry.risk},你按 ${declaredRisk} 声明`,
161
+ },
162
+ };
163
+ }
164
+ return { ok: true, entry };
165
+ }
166
+
167
+ /**
168
+ * 校验清单里的 `slots` 声明(清单校验期)。
169
+ *
170
+ * **逐项降级**:非法项进 `errors`,合法项照常进 `slots` —— 绝不整体拒绝。
171
+ * 理由:插件因为一个拼错的插槽名就从 /api/plugins 里消失,用户看到的是"插件没了",
172
+ * 比"某个插槽没生效"难查一个数量级。
173
+ * @param {unknown} raw plugin.json 里的 slots 字段
174
+ * @returns {{slots: string[], errors: object[]}} slots 是**已通过校验**的声明(去重、保序)
175
+ */
176
+ export function validateDeclaredSlots(raw, currentStage = CURRENT_STAGE) {
177
+ const slots = [];
178
+ const errors = [];
179
+ if (raw === undefined || raw === null) return { slots, errors };
180
+ if (!Array.isArray(raw)) {
181
+ errors.push({ name: '', why: 'bad-shape', hint: 'slots 必须是字符串数组' });
182
+ return { slots, errors };
183
+ }
184
+ for (const item of raw) {
185
+ if (typeof item !== 'string' || !item.trim()) {
186
+ errors.push({ name: typeof item === 'string' ? item : String(item), why: 'bad-shape', hint: 'slots 的每一项必须是非空字符串' });
187
+ continue;
188
+ }
189
+ const name = item.trim();
190
+ if (slots.includes(name)) continue; // 重复声明:静默去重(不是错误,作者只是写了两遍)
191
+ const entry = matchSlot(name);
192
+ if (!entry) {
193
+ errors.push({ name, why: 'unknown-slot', hint: '插槽目录里没有这一项(见 /api/plugins 的 slotCatalog)' });
194
+ continue;
195
+ }
196
+ if (!isSlotOpen(entry, currentStage)) {
197
+ errors.push({ name, why: 'slot-not-open', catalogStage: entry.stage, hint: `该插槽尚未开放(阶段 ${entry.stage}+)` });
198
+ continue;
199
+ }
200
+ slots.push(name);
201
+ }
202
+ return { slots, errors };
203
+ }
204
+
205
+ /** settings 第一版只支持两种类型(定案口径 ①):number/slider 留第二版(涉及范围/步进校验) */
206
+ export const SETTING_TYPES = ['enum', 'boolean'];
207
+
208
+ /**
209
+ * 设置键的强制前缀:`plugin.<pluginId>.`
210
+ *
211
+ * 为什么必须带前缀(与设计 §8.2 的键值插槽同口径):能力键白名单是**全局合并**的
212
+ * (`capabilities.settings` 一个数组),两个插件都声明 `sharpness` 时白名单里会出现两条同名字符串,
213
+ * 而"哪一条生效"取决于并入顺序 —— 那是不可调试的。带前缀后撞名从"可能"变成"不可能"。
214
+ * 为什么是 `plugin.` 而不是别的:键值插槽(`panel.control.<key>`)已经用这个前缀,
215
+ * 两条通道口径必须一致,否则同一个插件在两处的键长得不一样。
216
+ */
217
+ export const SETTING_KEY_PREFIX = 'plugin.';
218
+
219
+ /**
220
+ * 校验清单里的 `settings` 声明(清单校验期),同样是**逐项降级**。
221
+ *
222
+ * @param {unknown} raw plugin.json 里的 settings 字段
223
+ * @returns {{settings: object[], errors: object[]}}
224
+ * settings 每项归一成 {key, type, label, options?, default}(只留这些字段,清单里多写的不透传)
225
+ */
226
+ export function validateDeclaredSettings(raw, pluginId = '') {
227
+ const settings = [];
228
+ const errors = [];
229
+ if (raw === undefined || raw === null) return { settings, errors };
230
+ if (!Array.isArray(raw)) {
231
+ errors.push({ key: '', why: 'bad-shape', hint: 'settings 必须是数组' });
232
+ return { settings, errors };
233
+ }
234
+ // 没给 pluginId 就不校验前缀(缺省向后兼容:老调用点不受影响,也不会误判)
235
+ const requiredPrefix = pluginId ? `${SETTING_KEY_PREFIX}${pluginId}.` : null;
236
+ const seen = new Set();
237
+ for (const item of raw) {
238
+ const key = item && typeof item === 'object' && typeof item.key === 'string' ? item.key.trim() : '';
239
+ if (!key) {
240
+ errors.push({ key: '', why: 'bad-shape', hint: 'settings 的每一项必须带非空字符串 key' });
241
+ continue;
242
+ }
243
+ // 前缀校验放在"key 非空"之后、"类型"之前:先判形状再判语义
244
+ if (requiredPrefix && !key.startsWith(requiredPrefix)) {
245
+ errors.push({
246
+ key, why: 'key-prefix',
247
+ hint: `settings 的 key 必须以 ${requiredPrefix} 开头(能力键白名单是全局合并的,不带前缀会撞名)`,
248
+ });
249
+ continue;
250
+ }
251
+ if (seen.has(key)) {
252
+ errors.push({ key, why: 'duplicate-key', hint: '同一个 key 声明了多次,只保留第一条' });
253
+ continue;
254
+ }
255
+ const type = String((item && item.type) || '');
256
+ if (!SETTING_TYPES.includes(type)) {
257
+ errors.push({ key, why: 'bad-type', hint: `type 只支持 ${SETTING_TYPES.join(' / ')}(number/slider 留第二版)` });
258
+ continue;
259
+ }
260
+ const label = item && typeof item.label === 'string' ? item.label : key;
261
+ if (type === 'enum') {
262
+ const options = Array.isArray(item.options) ? item.options.map(String) : [];
263
+ if (options.length < 2) {
264
+ errors.push({ key, why: 'bad-options', hint: 'enum 至少要两个 options' });
265
+ continue;
266
+ }
267
+ const def = item.default === undefined ? options[0] : item.default;
268
+ if (!options.includes(String(def))) {
269
+ errors.push({ key, why: 'bad-default', hint: `default(${String(def)}) 不在 options 里` });
270
+ continue;
271
+ }
272
+ seen.add(key);
273
+ settings.push({ key, type, label, options, default: String(def) });
274
+ continue;
275
+ }
276
+ // boolean
277
+ const def = item.default === undefined ? false : item.default;
278
+ if (typeof def !== 'boolean') {
279
+ errors.push({ key, why: 'bad-default', hint: 'boolean 的 default 必须是 true/false' });
280
+ continue;
281
+ }
282
+ seen.add(key);
283
+ settings.push({ key, type, label, default: def });
284
+ }
285
+ return { settings, errors };
286
+ }
@@ -0,0 +1,56 @@
1
+ // routes/background-solids.mjs —— 纯色背景预设 5 个接口
2
+ //
3
+ // 为什么单独一个路由文件而不并进 backgrounds.mjs:
4
+ // backgrounds.mjs 那条链路是**二进制上传**(边读边计数、超限立即断开,文件头已写明
5
+ // "混在总入口里容易被误改成 readBody")。纯色预设全是小 JSON,用普通 readBody 即可,
6
+ // 两者放在一起会让"哪条能 readBody"变成需要读完整文件的判断题。
7
+ //
8
+ // 这一层只做三件事:认路径 + 读 body + 转发给数据层(校验/落盘/自愈全在
9
+ // http/background-solids.mjs),所以 11 条测试直接打数据层,不必起 HTTP。
10
+ //
11
+ // 并发约定见 docs/契约/数据与接口.md §四(单实例串行一致;多实例不保证)。
12
+
13
+ /** 路由前缀(这组接口的统一前缀,便于一眼看出归属) */
14
+ const BASE = '/api/backgrounds/solids';
15
+
16
+ /**
17
+ * @param {import('node:http').IncomingMessage} req
18
+ * @param {import('node:http').ServerResponse} res
19
+ * @param {string} p 路径(不含查询串)
20
+ * @param {object} ctx 需要 solids / sendJson / readBody
21
+ * @returns {boolean} 是否已处理
22
+ */
23
+ export function handleBackgroundSolids(req, res, p, ctx) {
24
+ const { solids, sendJson, readBody } = ctx;
25
+ if (!p.startsWith(BASE)) return false;
26
+
27
+ // GET:列表
28
+ if (p === BASE && req.method === 'GET') {
29
+ const r = solids.list();
30
+ sendJson(res, r.status || 200, r.body);
31
+ return true;
32
+ }
33
+
34
+ if (req.method !== 'POST') return false;
35
+
36
+ /** 读 JSON body 后执行一个动作(错误文案原样回给前端显示) */
37
+ const runWithBody = (fn) => {
38
+ readBody(req, res, (raw) => {
39
+ let body = {};
40
+ try { body = raw ? JSON.parse(raw) : {}; } catch {
41
+ return sendJson(res, 400, { error: '请求体不是合法 JSON' });
42
+ }
43
+ fn(body)
44
+ .then((r) => sendJson(res, r.status, r.body))
45
+ .catch((e) => sendJson(res, 500, { error: String((e && e.message) || e) }));
46
+ });
47
+ return true;
48
+ };
49
+
50
+ if (p === BASE) return runWithBody((body) => solids.add(body));
51
+ if (p === `${BASE}/rename`) return runWithBody((body) => solids.rename(body));
52
+ if (p === `${BASE}/remove`) return runWithBody((body) => solids.remove(body));
53
+ if (p === `${BASE}/reset`) return runWithBody(() => solids.reset());
54
+
55
+ return false;
56
+ }
@@ -0,0 +1,77 @@
1
+ // routes/backgrounds.mjs —— 自定义背景图 5 个接口(列表 / 上传 / 改名 / 删除 / 取文件)
2
+ //
3
+ // 为什么单独一组:上传走**原始二进制**(零依赖,不解析 multipart),且边读边计数、
4
+ // 超限立刻断开——与其它接口"先收齐再处理"的做法相反,混在总入口里容易被误改成 readBody。
5
+ // 文件落在 data/backgrounds/(运行时数据,换机/备份需单独带走)。
6
+
7
+ /**
8
+ * @param {import('node:http').IncomingMessage} req
9
+ * @param {import('node:http').ServerResponse} res
10
+ * @param {string} p
11
+ * @param {object} ctx 需要 backgrounds / sendJson / sendFile / readBody
12
+ * @returns {boolean} 是否已处理
13
+ */
14
+ export function handleBackgrounds(req, res, p, ctx) {
15
+ const { backgrounds, sendJson, sendFile, readBody } = ctx;
16
+
17
+ if (p === '/api/backgrounds' && req.method === 'GET') {
18
+ sendJson(res, 200, backgrounds.list());
19
+ return true;
20
+ }
21
+ if (p === '/api/backgrounds' && req.method === 'POST') {
22
+ // 注意:这条**不能**用 readBody——它会把整个请求体读进内存;
23
+ // backgrounds 自己边读边计数,超限立刻断开(小内存机 + 5MB×并发会 OOM)。
24
+ backgrounds.uploadStream(req)
25
+ .then((r) => {
26
+ sendJson(res, r.status, r.body);
27
+ // 超限/中断:响应先落地,再稍微缓一下才断开。
28
+ // 立刻 destroy 会让浏览器 fetch 只看到网络错误(拿不到 413 与 reason,
29
+ // 用户就只能看到笼统的"上传失败");缓 800ms 足够响应刷出去,
30
+ // 同时不让一个超大上传一直占着连接。
31
+ if (r.destroy) setTimeout(() => { try { req.destroy(); } catch { /* 已断开 */ } }, 800);
32
+ })
33
+ .catch((e) => sendJson(res, 500, { ok: false, reason: 'server-error', error: String((e && e.message) || e) }));
34
+ return true;
35
+ }
36
+ if (p === '/api/backgrounds/rename' && req.method === 'POST') {
37
+ readBody(req, res, (raw) => {
38
+ let body = {};
39
+ try { body = raw ? JSON.parse(raw) : {}; } catch {
40
+ return sendJson(res, 400, { ok: false, reason: 'bad-json', error: '请求体不是合法 JSON' });
41
+ }
42
+ backgrounds.rename(body.id, body.name)
43
+ .then((r) => sendJson(res, r.status, r.body))
44
+ .catch((e) => sendJson(res, 500, { ok: false, reason: 'server-error', error: String((e && e.message) || e) }));
45
+ });
46
+ return true;
47
+ }
48
+ if (p === '/api/backgrounds/delete' && req.method === 'POST') {
49
+ readBody(req, res, (raw) => {
50
+ let body = {};
51
+ try { body = raw ? JSON.parse(raw) : {}; } catch {
52
+ return sendJson(res, 400, { ok: false, reason: 'bad-json', error: '请求体不是合法 JSON' });
53
+ }
54
+ backgrounds.remove(body.id)
55
+ .then((r) => sendJson(res, r.status, r.body))
56
+ .catch((e) => sendJson(res, 500, { ok: false, reason: 'server-error', error: String((e && e.message) || e) }));
57
+ });
58
+ return true;
59
+ }
60
+ // GET 与 HEAD 都要接:代理/预取会先发 HEAD 探测,只认 GET 会让它拿到 404 的 JSON 头
61
+ if (p.startsWith('/api/backgrounds/file/') && (req.method === 'GET' || req.method === 'HEAD')) {
62
+ // id 只用于查索引,绝不拼进路径(用户可控输入)
63
+ const id = decodeURIComponent(p.slice('/api/backgrounds/file/'.length));
64
+ const hit = backgrounds.fileOf(id);
65
+ if (!hit) {
66
+ sendJson(res, 404, { ok: false, reason: 'not-found', error: `没有这张背景图:${id}` });
67
+ return true;
68
+ }
69
+ sendFile(req, res, hit.abs, {
70
+ 'Content-Type': hit.mime,
71
+ // 同一个 id 的文件内容不会变(重命名只改显示名),可以长缓存
72
+ 'Cache-Control': 'public, max-age=31536000, immutable',
73
+ });
74
+ return true;
75
+ }
76
+ return false;
77
+ }
@@ -0,0 +1,38 @@
1
+ // routes/config.mjs —— 配置导出 / 导入
2
+ //
3
+ // 导出是只读(把 config/resources.json 与 names/*.json 打包成 {files,count});
4
+ // 导入是写路径,与 /api/roots 共用 http/write.mjs 的"校验 → 备份 → 写 → 回读自检"管道。
5
+ //
6
+ // 为什么请求体上限单独放宽到 5MB:名表 JSON 可能较大,
7
+ // 默认 2MB 会让大名表在中途被断开,用户只看到笼统的失败。
8
+
9
+ /**
10
+ * @param {import('node:http').IncomingMessage} req
11
+ * @param {import('node:http').ServerResponse} res
12
+ * @param {string} p
13
+ * @param {object} ctx 需要 data / writeApi / sendJson / readBody
14
+ * @returns {boolean} 是否已处理
15
+ */
16
+ export function handleConfig(req, res, p, ctx) {
17
+ const { data, writeApi, sendJson, readBody } = ctx;
18
+
19
+ if (p === '/api/config/export' && req.method === 'GET') {
20
+ try {
21
+ const files = data.config.exportConfigFiles();
22
+ sendJson(res, 200, { files, count: Object.keys(files).length });
23
+ } catch (e) {
24
+ sendJson(res, 500, { error: `导出配置失败: ${e.message}` });
25
+ }
26
+ return true;
27
+ }
28
+ if (p === '/api/config/import' && req.method === 'POST') {
29
+ // 导入配置(覆盖 resources.json / names/*.json):同一套校验 → 备份 → 写 → 回读自检。
30
+ readBody(req, res, (body) => {
31
+ writeApi.writeConfigImport(body)
32
+ .then((r) => sendJson(res, r.status, r.body))
33
+ .catch((e) => sendJson(res, 500, { ok: false, error: `导入配置异常: ${(e && e.message) || e}` }));
34
+ }, 5e6);
35
+ return true;
36
+ }
37
+ return false;
38
+ }