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,94 @@
1
+ // bundle-probe.mjs —— UnityFS 资源包预检(/api/bundle/probe 的数据层)
2
+ //
3
+ // 只做容器级预检(不解码纹理像素),实现与插件共用 tool/lib/bundle-probe-node.mjs(同一份真相)。
4
+ // path 支持目录或文件:给目录时按稳定顺序挑资源包文件,并用 resolvedFile 回报实际用的那个。
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /**
9
+ * 定位要预检的资源包文件(目录时按稳定顺序挑一个;挑不出就要求给文件路径,不猜)。
10
+ * @returns {{ok: true, resolvedFile: string, absFile: string} | {ok: false, status: number, body: object}}
11
+ */
12
+ export function resolveProbeTarget(abs, rel) {
13
+ let resolvedFile = path.basename(abs);
14
+ let absFile = abs;
15
+ if (fs.statSync(abs).isDirectory()) {
16
+ const files = fs.readdirSync(abs, { withFileTypes: true })
17
+ .filter((e) => e.isFile() && !e.name.startsWith('.'))
18
+ .map((e) => e.name)
19
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
20
+ if (!files.length) {
21
+ return { ok: false, status: 404, body: { ok: false, error: '目录里没有文件', path: rel, code: 'ULB_ERR_NO_FILE' } };
22
+ }
23
+ const pick = files.length === 1 ? files[0] : files.find((n) => /\.(unity3d|bundle|assetbundle|bytes|ab)$/i.test(n));
24
+ if (!pick) {
25
+ return {
26
+ ok: false,
27
+ status: 400,
28
+ body: {
29
+ ok: false,
30
+ error: `目录里有 ${files.length} 个文件且无法判定哪个是资源包,请直接给文件路径`,
31
+ path: rel,
32
+ candidates: files.slice(0, 20),
33
+ code: 'ULB_ERR_BAD_ARGS',
34
+ },
35
+ };
36
+ }
37
+ resolvedFile = pick;
38
+ absFile = path.join(abs, pick);
39
+ }
40
+ if (!fs.existsSync(absFile) || !fs.statSync(absFile).isFile()) {
41
+ return { ok: false, status: 404, body: { ok: false, error: '资源包文件不存在', path: rel, code: 'ULB_ERR_NO_FILE' } };
42
+ }
43
+ return { ok: true, resolvedFile, absFile };
44
+ }
45
+
46
+ /**
47
+ * @param {{config: object, probe: object}} deps
48
+ */
49
+ export function createBundleProbe({ config, probe }) {
50
+ const { readConfig } = config;
51
+ const { resolveSafe } = probe;
52
+
53
+ /**
54
+ * @returns {Promise<{status: number, body: object}>}
55
+ */
56
+ async function probeBundle(rootId, rel) {
57
+ const t0 = Date.now();
58
+ const cfg = readConfig().find((r) => r.id === rootId);
59
+ if (!cfg) {
60
+ return { status: 400, body: { ok: false, error: `未找到资源根: ${rootId}`, code: 'ULB_ERR_BAD_ARGS' } };
61
+ }
62
+ const abs = resolveSafe(cfg.path, rel);
63
+ if (!abs) return { status: 403, body: { ok: false, error: '路径越界', code: 'ULB_ERR_BAD_ARGS' } };
64
+ if (!fs.existsSync(abs)) {
65
+ return { status: 404, body: { ok: false, error: '路径不存在', path: rel, code: 'ULB_ERR_NO_FILE' } };
66
+ }
67
+
68
+ const target = resolveProbeTarget(abs, rel);
69
+ if (!target.ok) return { status: target.status, body: target.body };
70
+
71
+ let probeBundleBytes;
72
+ try {
73
+ // 本文件在 src/server/assets/ ⇒ 到项目根的 tool/lib/ 要退三层(早前写成两层,
74
+ // 解析成 src/tool/lib/… ⇒ 该诊断端点一直返回「加载预检模块失败: Cannot find module」)。
75
+ ({ probeBundleBytes } = await import('../../../tool/lib/bundle-probe-node.mjs'));
76
+ } catch (e) {
77
+ return {
78
+ status: 500,
79
+ body: { ok: false, error: `加载预检模块失败: ${e.message}`, code: 'ULB_ERR_CONTAINER_PARSE', ms: Date.now() - t0 },
80
+ };
81
+ }
82
+
83
+ let body;
84
+ try {
85
+ const bytes = new Uint8Array(fs.readFileSync(target.absFile));
86
+ body = probeBundleBytes(bytes);
87
+ } catch (e) {
88
+ body = { ok: false, error: e.message, code: 'ULB_ERR_CONTAINER_PARSE' };
89
+ }
90
+ return { status: 200, body: Object.assign({ resolvedFile: target.resolvedFile }, body, { ms: Date.now() - t0 }) };
91
+ }
92
+
93
+ return { probeBundle };
94
+ }
@@ -0,0 +1,184 @@
1
+ // probe.mjs —— 只读探测工具:版本号、atlas 键、贴图页、路径越界防护、MIME 表
2
+ //
3
+ // 都是纯函数或「只读文件头」的低成本操作,不持有状态、不写盘。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /** JS 系扩展名共用的 MIME:.js/.mjs/.mts/.cjs 四个键映射同一值,只在这里写一次 */
8
+ const MIME_JS = 'text/javascript';
9
+ /** 二进制兜底 MIME:.skel/.moc/.mtn 都是八位字节流 */
10
+ const MIME_OCTET = 'application/octet-stream';
11
+ const MIME = {
12
+ '.html': 'text/html', '.htm': 'text/html', '.js': MIME_JS, '.mjs': MIME_JS,
13
+ '.mts': MIME_JS, '.cjs': MIME_JS, '.css': 'text/css', '.png': 'image/png',
14
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
15
+ '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.woff': 'font/woff',
16
+ '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.atlas': 'text/plain', '.skel': MIME_OCTET,
17
+ '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
18
+ '.txt': 'text/plain', '.map': 'application/json', '.webm': 'video/webm',
19
+ // Live2D 相关(天命之子资源):moc 是二进制模型、mtn 是二进制动作,都按八位字节流发
20
+ '.moc': MIME_OCTET, '.mtn': MIME_OCTET,
21
+ '.moc3': 'application/octet-stream', '.phy': 'application/json',
22
+ };
23
+
24
+ const SPINE_EXT = new Set(['.skel', '.atlas', '.png', '.json', '.jpg', '.jpeg', '.webp']);
25
+
26
+ /** 版本归一到主次版本(照抄 原版实现的 Ew:4.1.24 -> 4.1)。 */
27
+ export function normalizeVersion(v) {
28
+ const t = String(v).split('.');
29
+ return t.length >= 2 ? `${t[0]}.${t[1]}` : String(v);
30
+ }
31
+
32
+ /**
33
+ * 探测二进制 .skel 的 Spine 版本(**照抄 原版实现的 Mw**)。
34
+ *
35
+ * 为什么不能用宽松正则:原版 只认它支持的版本区间
36
+ * (2.x / 3.5-3.8 / 4.0-4.3 形式,后接点号与补丁号),
37
+ * 避免误匹配文件头里 hash 等处的数字。
38
+ * 探测不到时返回 **"4.0"**(与 原版 默认值一致)——
39
+ * 此前我返回空串,导致前端 getRuntimeWithFallback("") 选错运行时、渲染与 8801 不一致。
40
+ */
41
+ export function detectSkelVersion(file) {
42
+ try {
43
+ const fd = fs.openSync(file, 'r');
44
+ const buf = Buffer.alloc(512);
45
+ const read = fs.readSync(fd, buf, 0, 512, 0);
46
+ fs.closeSync(fd);
47
+ if (read <= 0) return '4.0';
48
+ const s = buf.slice(0, read).toString('latin1')
49
+ .match(/(2\.\d+|3\.[5-8]|4\.[0-3])\.\d+[\w-]*/);
50
+ return s ? normalizeVersion(s[0]) : '4.0';
51
+ } catch {
52
+ return '4.0';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 探测 JSON 骨架的 Spine 版本(**照抄 原版实现的 ww**)。
58
+ *
59
+ * 此前我只对 .skel 探测,导致 41 个 .json 骨架模型 version 为空串(渲染不一致)。
60
+ * skeleton.spine 位于文件开头,故只读前 4KB,避免为 MB 级骨架整文件读盘。
61
+ */
62
+ export function detectJsonVersion(file) {
63
+ try {
64
+ const fd = fs.openSync(file, 'r');
65
+ const buf = Buffer.alloc(4096);
66
+ const read = fs.readSync(fd, buf, 0, 4096, 0);
67
+ fs.closeSync(fd);
68
+ if (read <= 0) return '4.0';
69
+ const m = buf.slice(0, read).toString('utf8').match(/"spine"\s*:\s*"([^"]+)"/);
70
+ return m ? normalizeVersion(m[1]) : '4.0';
71
+ } catch {
72
+ return '4.0';
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 解析 atlas 的 PMA(预乘 Alpha)声明(**照抄 原版实现的 Tp**)。
78
+ *
79
+ * 返回 true / false / **null(未声明 = 未知)**。
80
+ * 为什么不能硬编码 false:BD2 系 atlas 均未声明 pma,正确语义是"未知",
81
+ * 应由前端做像素检测;硬编码 false 等于断言"非预乘",会污染其判定,
82
+ * 导致与原始 原版 渲染不一致(实测踩坑)。
83
+ */
84
+ export function detectAtlasPma(file) {
85
+ try {
86
+ const m = fs.readFileSync(file, 'utf8').match(/^pma:\s*(true|false)/m);
87
+ return m ? m[1] === 'true' : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 探测 Spine 二进制 .skel 文件的版本号(如 "4.1")。
95
+ *
96
+ * 原理:Spine 二进制导出时,文件头区域包含版本字符串。
97
+ * 实测 BD2 资源前 40 字节可见 "4.1.11"(偏移约 8-14)。
98
+ * 只读前 64 字节 + 正则提取,成本极低(无需读取整个文件)。
99
+ *
100
+ * 返回主次版本(如 "4.1"),供模型对象选择对应运行时;探测失败返回 null,
101
+ * 由调用方回退到自动识别模式(原版实现的 runtimeMode:"auto")。
102
+ *
103
+ * @param {string} file 绝对路径
104
+ * @returns {string|null} 如 "4.1"
105
+ */
106
+ /**
107
+ * 从 .atlas 内容首行提取资源标识(归一 key 的来源)。
108
+ *
109
+ * 为什么从内容取而不是用目录名:
110
+ * 同一资源的目录命名可能大小写不统一(实测 Char000203_2 与 char000203),
111
+ * 且不同资源根/不同层级下目录结构各异;而 atlas 首行引用的贴图名是资源内部
112
+ * 一致的信息,用它做归一 key 更可靠 —— 原版与 Mod 只要贴图名相同即归一。
113
+ *
114
+ * 规则(刻意简化,不多此一举):仅去扩展名 + 转小写。
115
+ * 保留 cutscene_ 前缀(立绘/技能是真实区分)、保留 _2/_3 形态后缀。
116
+ * 例:char000206.png -> "char000206"
117
+ * CutScene_Char000203_2.png -> "cutscene_char000203_2"
118
+ *
119
+ * @param {string} file atlas 绝对路径
120
+ * @returns {string|null}
121
+ */
122
+ export function detectAtlasKey(file) {
123
+ try {
124
+ const fd = fs.openSync(file, 'r');
125
+ const buf = Buffer.alloc(256);
126
+ const read = fs.readSync(fd, buf, 0, 256, 0);
127
+ fs.closeSync(fd);
128
+ if (read <= 0) return null;
129
+ const firstLine = buf.slice(0, read).toString('utf8').split('\n')[0].trim();
130
+ if (!firstLine) return null;
131
+ return firstLine.replace(/\.[^.]+$/, '').toLowerCase();
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /** 与前端 local-roots.js 的 buildModel 保持一致的构造逻辑(服务端版)。 */
138
+ /**
139
+ * 解析 .atlas 内容,取出它引用的贴图页文件名。
140
+ *
141
+ * Spine atlas 格式:每个贴图页以「未缩进的图片文件名」开头(多页 atlas 会有多行这样的页头),
142
+ * 其后是缩进的属性行(size:/filter:/bounds:/rotate: 等);区域名也可能未缩进,但不带图片扩展名。
143
+ * 因此判定规则:**未缩进 且 以图片扩展名结尾**的行 = 贴图页头。
144
+ *
145
+ * 为什么必须解析 atlas:同一目录可能同时存在多个资源
146
+ * (实测 066402/cutscene 下有 cutscene_char066402 与 cutscene_char066402_camera),
147
+ * 若把「目录里所有图片」都当贴图,会把别的资源的贴图错配进来。
148
+ */
149
+ export function parseAtlasPages(atlasPath) {
150
+ try {
151
+ // 先剥一次 BOM 再按行匹配。原因:JS 的 \s **包含 U+FEFF**,而 AssetStudio 导出的 atlas
152
+ // 首行是「BOM + 页名」(EF BB BF ...)⇒ 不剥的话首行被 /^[^\s]/ 判成"有前导空白"整行跳过
153
+ // ⇒ 多页图集的**第一页页名丢失**(现场:NIKKE 根 247 个模型里 34 份 atlas 带 BOM,均丢第一页 ⇒ 贴图拿不到、画面空白)。
154
+ // 为什么前端 src/web/engine/textures.js 没这个坑:它**先 trim() 再匹配**,而 trim() 会把 U+FEFF
155
+ // 当空白吃掉 —— 所以那边页名本来就干净。这里剥 BOM 就是与"先 trim"等价,别删。
156
+ // 只剥解析用的文本:**不改 atlas 落盘字节、不改页名大小写**(产物必须逐字节不变)。
157
+ const txt = fs.readFileSync(atlasPath, 'utf8').replace(/^\uFEFF/, '');
158
+ const pages = [];
159
+ for (const line of txt.split('\n')) {
160
+ if (/^[^\s].*\.(png|jpg|jpeg|webp)\s*$/i.test(line)) pages.push(line.trim());
161
+ }
162
+ return pages;
163
+ } catch {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ /** 校验并解析出安全的绝对路径;越界返回 null。 */
169
+ export function resolveSafe(rootBase, relPath) {
170
+ const rel = String(relPath || '').replace(/^\/+/, '');
171
+ const abs = path.resolve(rootBase, rel);
172
+ const base = path.resolve(rootBase);
173
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null;
174
+ return abs;
175
+ }
176
+
177
+ // 逐段编码路径:保留 / 分隔符,其余段各自 encodeURIComponent。
178
+ // 为什么:Live2D 资源目录含中文(皮肤名)与括号等字符,必须编码;
179
+ // 但整体 encodeURIComponent 会把 / 也编掉,破坏路径结构。
180
+ export function encodePath(p) {
181
+ return String(p).split('/').map(encodeURIComponent).join('/');
182
+ }
183
+
184
+ export { MIME, SPINE_EXT };
@@ -0,0 +1,173 @@
1
+ // config.mjs —— 资源根配置的读写与分组视图(config/resources.json 是唯一权威)
2
+
3
+ //
4
+ // 写回时保留原文件的注释头,避免系统写一次就把人工写的说明冲掉;
5
+ // 写入走 tmp + rename,避免读到写了一半的内容。
6
+ import fs from 'node:fs';
7
+ import { createLogger } from './log.mjs';
8
+ import path from 'node:path';
9
+ import { parseJsonc } from '../../../tool/parse-jsonc.mjs';
10
+
11
+ /** 服务端日志(统一形状 {at,level,tag,msg,detail};error→stderr,其余→stdout) */
12
+ const log = createLogger('config');
13
+
14
+ /** @param {{root: string, configFile: string}} p */
15
+ export function createConfig(p) {
16
+ const ROOT = p.root;
17
+ const CONFIG_FILE = p.configFile;
18
+
19
+ /**
20
+ * 读配置(JSONC),返回生效的资源根数组。
21
+ *
22
+ * 职责划分(重要):
23
+ * 资源根目录列表**只由 config/resources.json 负责**(唯一权威),
24
+ * 由用户在界面上选择文件夹后自动生成/写入(见 POST /api/roots)。
25
+ * env 只负责部署差异类的单值(PORT、SPINE_RUNTIME_DIR 等),
26
+ * 不用于增删资源根 —— 结构性列表交给 JSON,避免两套真相源。
27
+ *
28
+ * @returns {Array<{id:string,name:string,path:string,enabled:boolean}>}
29
+ */
30
+ function readConfig() {
31
+ try {
32
+ const cfg = parseJsonc(fs.readFileSync(CONFIG_FILE, 'utf8'), 'config/resources.json');
33
+ const roots = Array.isArray(cfg.roots) ? cfg.roots : [];
34
+ return roots.filter((r) => r && r.id && r.path && r.enabled !== false);
35
+ } catch (e) {
36
+ log.error('读取 config/resources.json 失败', { err: e.message });
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 读配置里的**全部**资源根(含 enabled:false 的),供写路径使用。
43
+ *
44
+ * 为什么写路径不能复用 readConfig():
45
+ * readConfig() 是「运行时生效视图」,会把 enabled:false 的根过滤掉;
46
+ * 若拿它的结果写回文件,停用的根就会被静默删除(改一次别的根就丢一条配置)。
47
+ * 写路径必须基于完整列表做增删改,停用与否只是某个字段的值。
48
+ *
49
+ * 只做最小形状校验(id + path 必须存在),其余字段原样保留:
50
+ * roots[] 还承载 names / icons / kind / profile 等附加配置,写回时不能丢。
51
+ *
52
+ * @returns {Array<object>} 完整资源根数组(保持文件中的顺序)
53
+ */
54
+ function readConfigRaw() {
55
+ try {
56
+ const cfg = parseJsonc(fs.readFileSync(CONFIG_FILE, 'utf8'), 'config/resources.json');
57
+ const roots = Array.isArray(cfg.roots) ? cfg.roots : [];
58
+ return roots.filter((r) => r && typeof r === 'object' && r.id && r.path);
59
+ } catch (e) {
60
+ // 读不出来时抛出而不是返回空数组:写路径拿空列表写回会把整份配置清空
61
+ throw new Error(`读取 config/resources.json 失败: ${e.message}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 写回 config/resources.json(保留原文件注释头部)。
67
+ *
68
+ * 为什么保留注释头:配置文件带 // 注释(JSONC),若整文件用 JSON.stringify 覆盖,
69
+ * 注释会被冲掉;而注释是给"人"看的说明,系统自动生成时也应保留。
70
+ *
71
+ * 头部怎么取(重要):只取**文件开头的连续注释/空行块**,不能"截到第一个 '{'"。
72
+ * 因为注释正文里也可能出现花括号(例如 icons 说明写了 `{ dir: 绝对路径 }`),
73
+ * 按第一个 { 截断会把注释切成半行、且新 JSON 的首行被并进该注释行 —— 整份文件随即解析失败
74
+ * (实测:写入成功但回读报 "Unexpected non-whitespace character after JSON",配置等于写坏)。
75
+ *
76
+ * 原子写入:先写 .tmp 再 rename,避免写到一半中断导致配置损坏。
77
+ *
78
+ * @param {Array} roots 资源根数组
79
+ */
80
+ function writeConfig(roots) {
81
+ let header = readHeader();
82
+ if (!header) {
83
+ header = [
84
+ '// 本地资源根配置 —— 由界面「添加资源根」自动生成,也可手动编辑',
85
+ '// 文件格式:JSONC(标准 JSON + // 注释);注释由 tool/parse-jsonc.mjs 剥离',
86
+ '// 字段:id(唯一标识) / name(显示名) / path(本机绝对路径) / enabled(默认 true)',
87
+ '',
88
+ ].join('\n');
89
+ }
90
+
91
+ const body = JSON.stringify({ roots }, null, 2);
92
+ fs.mkdirSync(path.dirname(CONFIG_FILE + '.tmp'), { recursive: true });
93
+ fs.writeFileSync(CONFIG_FILE + '.tmp', header + body + '\n', 'utf8');
94
+ fs.renameSync(CONFIG_FILE + '.tmp', CONFIG_FILE);
95
+ }
96
+
97
+ /**
98
+ * 取配置文件开头的注释块(连续的行首 // 与空行),作为写回时要保留的 header。
99
+ *
100
+ * 判据按**行**而不是按字符:从第一行开始,遇到第一行"不是注释也不是空行"就停。
101
+ * 这样注释里出现的花括号、引号都不会影响截断位置;返回值保证以换行结尾,
102
+ * 让新 JSON 从独立一行开始。
103
+ *
104
+ * @returns {string} 以换行结尾的注释块;文件不存在或无注释块时返回空串
105
+ */
106
+ function readHeader() {
107
+ if (!fs.existsSync(CONFIG_FILE)) return '';
108
+ let old;
109
+ try {
110
+ old = fs.readFileSync(CONFIG_FILE, 'utf8');
111
+ } catch {
112
+ return '';
113
+ }
114
+ const lines = old.split('\n');
115
+ const keep = [];
116
+ for (const line of lines) {
117
+ const t = line.trim();
118
+ if (t === '' || t.startsWith('//')) keep.push(line);
119
+ else break;
120
+ }
121
+ if (!keep.length) return '';
122
+ return `${keep.join('\n')}\n`;
123
+ }
124
+
125
+ /**
126
+ * 按 name(显示名称 = 归一 key)把资源根归并成组,供前端直接渲染。
127
+ *
128
+ * 归一语义:name 相同的多个资源根合并为一个条目。
129
+ * 例:「棕色尘埃2 原版」与「棕色尘埃2 mod」两个目录若都命名为"棕色尘埃2",
130
+ * 界面合并成一个"棕色尘埃2",其下汇总各 root 的模型(原版 + mod 共存不覆盖)。
131
+ *
132
+ * @param {Array} roots 资源根数组
133
+ * @returns {Array<{name:string, rootCount:number, roots:Array}>}
134
+ */
135
+ function buildGroups(roots) {
136
+ const map = new Map();
137
+ for (const r of roots) {
138
+ if (!map.has(r.name)) map.set(r.name, { name: r.name, rootCount: 0, roots: [] });
139
+ const g = map.get(r.name);
140
+ g.rootCount++;
141
+ g.roots.push({ id: r.id, path: r.path, exists: fs.existsSync(r.path) && fs.statSync(r.path).isDirectory() });
142
+ }
143
+ return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
144
+ }
145
+
146
+ /** 可导出/导入的配置文件清单(相对 config/ 的路径)。 */
147
+ function exportableConfigFiles() {
148
+ const files = ['resources.json'];
149
+ const namesDir = path.join(ROOT, 'config', 'names');
150
+ try {
151
+ for (const n of fs.readdirSync(namesDir)) {
152
+ if (n.endsWith('.json')) files.push(`names/${n}`);
153
+ }
154
+ } catch { /* names 目录不存在则只有 resources.json */ }
155
+ return files;
156
+ }
157
+
158
+ /** 读取全部可导出配置的原始文本。 */
159
+ function exportConfigFiles() {
160
+ const out = {};
161
+ for (const rel of exportableConfigFiles()) {
162
+ try {
163
+ out[rel] = fs.readFileSync(path.join(ROOT, 'config', rel), 'utf8');
164
+ } catch { /* 读不到就跳过该文件 */ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // readConfig = 运行时生效视图(已滤掉 enabled:false),只读消费方用它
170
+ // readConfigRaw = 完整列表(含停用),写路径必须用它,否则会丢配置
171
+ return { readConfig, readConfigRaw, writeConfig, buildGroups, exportableConfigFiles, exportConfigFiles, CONFIG_FILE };
172
+ }
173
+
@@ -0,0 +1,60 @@
1
+ // core/env-num.mjs —— 数值型 env 的统一读取(含非法值回落 + 告警)
2
+ //
3
+ // 为什么单独成模块:准入/探测这几处调参都要求"非法值回落缺省 + 一条 warn",
4
+ // 各写一遍就会出现"这处安静回落、那处直接 NaN 传下去"的分歧 ——
5
+ // 而 NaN 传进比较表达式不会报错,只会让判据恒为 false(最隐蔽的一类失效)。
6
+ //
7
+ // 约定(三处调用方一致):
8
+ // · 缺省(未设 / 空串)⇒ 直接返回 fallback,**不告警**(这是正常部署形态)
9
+ // · 设了但非法(NaN / 越界)⇒ 返回 fallback + **warn 一条**(不许静默用错值)
10
+ // · 合法 ⇒ 返回解析后的数字
11
+
12
+ /**
13
+ * 读一个非负数值型 env。
14
+ * @param {string} name 变量名
15
+ * @param {number} fallback 缺省值
16
+ * @param {(level: string, msg: string, detail?: object) => void} [log] 日志函数(缺省 console.warn)
17
+ * @returns {number}
18
+ */
19
+ export function readNumericEnv(name, fallback, log) {
20
+ const raw = (process.env[name] || '').trim();
21
+ if (!raw) return fallback;
22
+ const n = Number(raw);
23
+ if (Number.isFinite(n) && n >= 0) return n;
24
+ warnOnce(log, name, raw, fallback, '需要非负数字');
25
+ return fallback;
26
+ }
27
+
28
+ /**
29
+ * 读一个"比例"型 env(必须落在 (0, 1] 区间)。
30
+ * @param {string} name
31
+ * @param {number} fallback 缺省比例(如 0.85 / 0.5)
32
+ * @param {(level: string, msg: string, detail?: object) => void} [log]
33
+ * @returns {number}
34
+ */
35
+ export function readRatioEnv(name, fallback, log) {
36
+ const raw = (process.env[name] || '').trim();
37
+ if (!raw) return fallback;
38
+ const n = Number(raw);
39
+ if (Number.isFinite(n) && n > 0 && n <= 1) return n;
40
+ warnOnce(log, name, raw, fallback, '需要 (0,1] 区间的小数(例如 0.85)');
41
+ return fallback;
42
+ }
43
+
44
+ /** 告警(带去重:同一次启动里同一个变量只告一次,避免每请求刷屏) */
45
+ const warned = new Set();
46
+ function warnOnce(log, name, raw, fallback, hint) {
47
+ if (warned.has(name)) return;
48
+ warned.add(name);
49
+ const msg = `环境变量 ${name}=${JSON.stringify(raw)} 非法(${hint})—— 已回落缺省 ${fallback}`;
50
+ try {
51
+ if (typeof log === 'function') log('warn', msg, { name, raw, fallback });
52
+ else if (log && typeof log.warn === 'function') log.warn(msg, { name, raw, fallback });
53
+ else if (typeof console !== 'undefined' && console.warn) console.warn(msg);
54
+ } catch { /* 日志失败也不能影响取值 */ }
55
+ }
56
+
57
+ /** 测试用:清掉"已告警"记录(同进程多次构造时才能重新告警) */
58
+ export function resetEnvWarnings() {
59
+ warned.clear();
60
+ }
@@ -0,0 +1,49 @@
1
+ // env.mjs —— 项目根 .env 的极简解析(零依赖)
2
+
3
+ const log = createLogger('brand');
4
+ //
5
+ // 不引入 dotenv:只需要 KEY=VALUE、注释、成对引号剥除这几条规则。
6
+ // 每次调用都重读文件,改完 .env 刷新页面即生效。
7
+ import fs from 'node:fs';
8
+ import { createLogger } from './log.mjs';
9
+
10
+ /** @param {{dotenvFile: string}} p 由 createPaths() 产出 */
11
+ export function createEnv(p) {
12
+ const DOTENV_FILE = p.dotenvFile;
13
+ const ENV_FLAG_TRUE = new Set(['1', 'true', 'on', 'yes']);
14
+ const ENV_FLAG_FALSE = new Set(['0', 'false', 'off', 'no']);
15
+
16
+ function readDotEnv() {
17
+ const out = {};
18
+ try {
19
+ if (!fs.existsSync(DOTENV_FILE)) return out;
20
+ const text = fs.readFileSync(DOTENV_FILE, 'utf8');
21
+ for (const rawLine of text.split(/\r?\n/)) {
22
+ const line = rawLine.trim();
23
+ if (!line || line.startsWith('#')) continue;
24
+ const eq = line.indexOf('=');
25
+ if (eq <= 0) continue;
26
+ const key = line.slice(0, eq).trim();
27
+ let val = line.slice(eq + 1).trim();
28
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
29
+ val = val.slice(1, -1);
30
+ }
31
+ if (key) out[key] = val;
32
+ }
33
+ } catch (e) {
34
+ log.warn('读取 .env 失败,按内置默认处理', { err: e.message });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function parseEnvFlag(raw) {
40
+ const v = String(raw == null ? '' : raw).trim().toLowerCase();
41
+ if (!v) return null;
42
+ if (ENV_FLAG_TRUE.has(v)) return true;
43
+ if (ENV_FLAG_FALSE.has(v)) return false;
44
+ return null;
45
+ }
46
+
47
+ return { readDotEnv, parseEnvFlag, DOTENV_FILE };
48
+ }
49
+
@@ -0,0 +1,37 @@
1
+ // core/fs-portable.mjs —— 跨文件系统的可移植文件操作
2
+ //
3
+ // 为什么需要这一层:CIFS/SMB 挂载不允许改权限位,而 Node 的 fs.copyFileSync
4
+ // 在 libuv 层复制完字节后会 fchmod 把源权限位套到目标文件 —— 于是在 SMB 上
5
+ // 稳定报 EPERM。对照实验:
6
+ // fs.copyFileSync(src, dst) → EPERM
7
+ // readFileSync + writeFileSync → OK
8
+ // openSync('w') + writeSync + close → OK
9
+ // 所以不是目录不存在、也不是路径拼错,就是复制之后那次改权限。
10
+
11
+ import fs from 'node:fs';
12
+
13
+ /**
14
+ * 复制文件,在不能改权限的文件系统上自动降级。
15
+ *
16
+ * 策略:普通盘先走内核复制(大文件明显更快);只有 EPERM / ENOTSUP 这两种
17
+ * "这块文件系统不支持"的错误才降级成纯字节复制。其它错误一律往上抛 ——
18
+ * 绝不把真问题(源不存在、磁盘满、路径越界)也吞成"复制成功或静默失败"。
19
+ *
20
+ * 为什么不是 async:调用点(配置备份、损坏文件留证)都在同步流程里,
21
+ * 改成 Promise 会把整条写入管道染成异步,收益为零、风险变大。
22
+ *
23
+ * @param {string} src 源文件路径
24
+ * @param {string} dst 目标文件路径(父目录必须已存在,与 fs.copyFileSync 一致)
25
+ * @returns {void}
26
+ */
27
+ export function copyFilePortable(src, dst) {
28
+ try {
29
+ fs.copyFileSync(src, dst);
30
+ } catch (e) {
31
+ // 只对"这块文件系统不支持改权限"的两种错误降级;其它照抛
32
+ if (e.code !== 'EPERM' && e.code !== 'ENOTSUP') throw e;
33
+ // 纯字节复制:读全量再写回,不碰权限位,实测在 CIFS/SMB 上可用。
34
+ // 内存代价 = 文件大小(配置文件都是 KB 级;这里不做流式,避免再引入一条出错路径)
35
+ fs.writeFileSync(dst, fs.readFileSync(src));
36
+ }
37
+ }