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.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// infra/slot-registry.js —— 插件插槽注册表(**只登记、不渲染**)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块、且不碰 DOM:
|
|
4
|
+
// · 校验逻辑(四类拒绝 + 顺序)是本阶段最容易写错的部分,必须能在 `node --test` 里直接跑;
|
|
5
|
+
// 一旦 import 里带上 document/window,就只能靠浏览器套件测,回归成本高一个量级。
|
|
6
|
+
// · 装配层(plugin-host.js)只负责"把插件声明喂进来、把 disposer 挂进回收链",
|
|
7
|
+
// 渲染(S2)另外接 —— 模块边界就是"纯校验 + 纯登记"。
|
|
8
|
+
//
|
|
9
|
+
// 校验顺序**固定**(设计 §4.2,测试会钉住):
|
|
10
|
+
// ① 同步窗口(不在脚本装载期的调用直接拒,见下)
|
|
11
|
+
// ② unknown-slot 目录里没有这个名字
|
|
12
|
+
// ③ slot-not-open 目录里有,但阶段未到(必须先于 ④,否则会被误报成"没声明")
|
|
13
|
+
// ④ undeclared-slot 插件清单没声明它
|
|
14
|
+
// ⑤ risk-mismatch 声明了,但风险等级与目录不符(错误里带双方等级)
|
|
15
|
+
// 之后再查 spec 形状(id 必填 / 前缀 / 同插槽内重名)
|
|
16
|
+
|
|
17
|
+
/** 插槽名里的占位符写法:`panel.group.<groupId>` */
|
|
18
|
+
const PLACEHOLDER_RE = /<[^>]+>/;
|
|
19
|
+
|
|
20
|
+
/** 阶段序号:S1 < S2 < …(非 Sx 的值排最前,视为"未开放") */
|
|
21
|
+
function stageRank(stage) {
|
|
22
|
+
const m = /^S(\d+)$/i.exec(String(stage || '').trim());
|
|
23
|
+
return m ? Number(m[1]) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 把目录里的带占位符名字编译成匹配器。
|
|
28
|
+
* 例:`panel.group.<groupId>` → /^panel\.group\.\\S+$/
|
|
29
|
+
* @param {string} pattern
|
|
30
|
+
* @returns {{test: (name: string) => boolean, dynamic: boolean}}
|
|
31
|
+
*/
|
|
32
|
+
function compilePattern(pattern) {
|
|
33
|
+
const text = String(pattern || '');
|
|
34
|
+
if (!PLACEHOLDER_RE.test(text)) {
|
|
35
|
+
return { test: (name) => name === text, dynamic: false };
|
|
36
|
+
}
|
|
37
|
+
const escaped = text
|
|
38
|
+
.split(/(<[^>]+>)/)
|
|
39
|
+
// 占位符吃"一段非空、非空白",**允许带点**:设计 §8.2 要求 panel.control.<key> 的 key 写成
|
|
40
|
+
// plugin.<pluginId>.<key>(本身含两个点),禁点会让那条规范永远匹配不上。
|
|
41
|
+
.map((part) => (PLACEHOLDER_RE.test(part) ? '\\S+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
42
|
+
.join('');
|
|
43
|
+
const re = new RegExp(`^${escaped}$`);
|
|
44
|
+
return { test: (name) => re.test(name), dynamic: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 造一个插槽注册表。
|
|
49
|
+
* @param {{
|
|
50
|
+
* catalog?: Array<{name:string, type:string, risk:string, stage:string, desc?:string}>,
|
|
51
|
+
* declaredByPlugin?: Record<string, Array<{name:string, risk:string}>>,
|
|
52
|
+
* onReject?: (rej: object) => void,
|
|
53
|
+
* openStage?: string,
|
|
54
|
+
* isInLoadWindow?: () => boolean,
|
|
55
|
+
* }} [opts]
|
|
56
|
+
*/
|
|
57
|
+
export function createSlotRegistry(opts = {}) {
|
|
58
|
+
const catalog = Array.isArray(opts.catalog) ? opts.catalog : [];
|
|
59
|
+
const declaredByPlugin = opts.declaredByPlugin && typeof opts.declaredByPlugin === 'object'
|
|
60
|
+
? opts.declaredByPlugin : {};
|
|
61
|
+
const onReject = typeof opts.onReject === 'function' ? opts.onReject : () => {};
|
|
62
|
+
const openStage = String(opts.openStage || 'S1');
|
|
63
|
+
const isInLoadWindow = typeof opts.isInLoadWindow === 'function' ? opts.isInLoadWindow : () => true;
|
|
64
|
+
/**
|
|
65
|
+
* 已知的内置分组 id 集合(由装配层传入 PANEL_GROUPS 的 id)。
|
|
66
|
+
* 用途:区分"引用内置组"与"新建插件组"——前者直接放行,后者必须带 plugin.<pluginId>. 前缀。
|
|
67
|
+
*/
|
|
68
|
+
const groupIds = opts.groupIds instanceof Set ? opts.groupIds : new Set(opts.groupIds || []);
|
|
69
|
+
/**
|
|
70
|
+
* 某插件在清单里声明过的 settings 键(`plugin.json` 的 settings[].key)。
|
|
71
|
+
* 用途:校验 spec.setting —— 控件声明的存值键必须真的声明过,否则会写出一个
|
|
72
|
+
* 渲染层永远不认的孤儿键("改了没反应"类问题的源头)。
|
|
73
|
+
*/
|
|
74
|
+
const settingsKeysOf = typeof opts.settingsKeysOf === 'function' ? opts.settingsKeysOf : () => [];
|
|
75
|
+
/**
|
|
76
|
+
* 内置面板控件的 id 集合(`panel.control.<key>` 的 key 只能指向它们)。
|
|
77
|
+
* 为什么要有它:替换类插槽**不允许静默新建控件**(那是分组插槽的职责)——
|
|
78
|
+
* 指向不存在的 id 时必须当场拒绝并说清,否则插件以为替换成功、用户什么也没看到。
|
|
79
|
+
*/
|
|
80
|
+
/** 全局注册序号(单值插槽的"后注册胜出"依赖它) */
|
|
81
|
+
let seqCounter = 0;
|
|
82
|
+
const builtinControlIds = opts.builtinControlIds instanceof Set
|
|
83
|
+
? opts.builtinControlIds
|
|
84
|
+
: new Set(opts.builtinControlIds || []);
|
|
85
|
+
|
|
86
|
+
/** 目录项 + 编译后的匹配器(构造时算一次,注册期不重复编译) */
|
|
87
|
+
const entries = catalog.map((e) => ({ ...e, matcher: compilePattern(e && e.name) }));
|
|
88
|
+
/** 已登记的插槽:pluginId → [{ name, id, order, label, spec, catalogRisk }] */
|
|
89
|
+
const byPlugin = new Map();
|
|
90
|
+
/** 拒绝记录(最近 50 条,测试与排障都读它) */
|
|
91
|
+
const rejects = [];
|
|
92
|
+
|
|
93
|
+
/** 按名字找目录项(静态名优先,其次按占位符模式匹配) */
|
|
94
|
+
function findCatalogEntry(name) {
|
|
95
|
+
const hit = entries.find((e) => !e.matcher.dynamic && e.matcher.test(name));
|
|
96
|
+
if (hit) return hit;
|
|
97
|
+
return entries.find((e) => e.matcher.dynamic && e.matcher.test(name)) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 该插件在清单里声明过哪些插槽(服务端 slotErrors 里被丢的项不算声明成功) */
|
|
101
|
+
function declaredOf(pluginId) {
|
|
102
|
+
const list = declaredByPlugin[pluginId];
|
|
103
|
+
if (!Array.isArray(list)) return [];
|
|
104
|
+
// 服务端下发的 slots 是**字符串数组**(slot-catalog.mjs:171-188 只收非空字符串);
|
|
105
|
+
// 同时兼容 {name, risk} 形状(老版本/测试注入),统一成同一种记录
|
|
106
|
+
return list
|
|
107
|
+
.map((item) => (typeof item === 'string' ? { name: item.trim(), risk: null } : {
|
|
108
|
+
name: String((item && item.name) || '').trim(),
|
|
109
|
+
risk: item && item.risk ? String(item.risk) : null,
|
|
110
|
+
}))
|
|
111
|
+
.filter((d) => d.name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 记一条拒绝:给调用方 false、给 onReject、给 stats */
|
|
115
|
+
function reject(pluginId, name, code, why, detail) {
|
|
116
|
+
const rec = { pluginId: pluginId || null, name: name || null, code, why, at: Date.now(), ...(detail || {}) };
|
|
117
|
+
rejects.push(rec);
|
|
118
|
+
if (rejects.length > 50) rejects.shift();
|
|
119
|
+
try { onReject(rec); } catch { /* 拒绝回执失败不影响拒绝本身 */ }
|
|
120
|
+
return rec;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 已登记列表(按插件) */
|
|
124
|
+
function listOf(pluginId) {
|
|
125
|
+
return byPlugin.get(pluginId) || [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 登记一个插槽。
|
|
130
|
+
* @param {string} pluginId 注册者(必须与清单里的 id 一致)
|
|
131
|
+
* @param {string} name 插槽名,如 `panel.group.settings`
|
|
132
|
+
* @param {object} spec 插件声明:{ id, order?, label?, render?, onAction? }
|
|
133
|
+
* @returns {null | (() => void)} 成功返回 disposer;失败返回 null(原因走 onReject + stats)
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* 校验"这个插槽能不能被这个插件注册"(前三类 + 风险等级)。
|
|
137
|
+
* 顺序固定:unknown-slot → slot-not-open → undeclared-slot → risk-mismatch。
|
|
138
|
+
* @param {string} pid
|
|
139
|
+
* @param {string} slotName
|
|
140
|
+
* @returns {{ok: true, entry: object, catalogRisk: string} | {ok: false}}
|
|
141
|
+
*/
|
|
142
|
+
function checkSlotAllowed(pid, slotName, opts = {}) {
|
|
143
|
+
// ② 目录里没有
|
|
144
|
+
const entry = findCatalogEntry(slotName);
|
|
145
|
+
if (!entry) {
|
|
146
|
+
reject(pid, slotName, 'unknown-slot',
|
|
147
|
+
`插槽「${slotName}」不在插槽目录里(可用:${entries.map((e) => e.name).join(' / ')})`);
|
|
148
|
+
return { ok: false };
|
|
149
|
+
}
|
|
150
|
+
// ③ 阶段未到(必须先于 ④:否则"目录里有但没开放"会被误报成"插件没声明")
|
|
151
|
+
if (stageRank(entry.stage) > stageRank(openStage)) {
|
|
152
|
+
reject(pid, slotName, 'slot-not-open',
|
|
153
|
+
`插槽「${slotName}」在当前阶段(${openStage})尚未开放(目录标注 ${entry.stage})`,
|
|
154
|
+
{ catalogStage: entry.stage, openStage });
|
|
155
|
+
return { ok: false };
|
|
156
|
+
}
|
|
157
|
+
// ④ 插件清单没声明
|
|
158
|
+
// implicitDeclare(兼容层用):老接口 registerControls 的插件不可能在 plugin.json 里写 slots,
|
|
159
|
+
// 把它当成"隐式声明了它要注册的那个分组插槽"——否则老插件一升级就全部失效。
|
|
160
|
+
// 注意:隐式只作用于**声明检查**,前缀/阶段/风险等级/重名检查一条都不放过。
|
|
161
|
+
const declared = opts.implicitDeclare
|
|
162
|
+
? { name: slotName, risk: null }
|
|
163
|
+
: (declaredOf(pid).find((d) => d.name === slotName) || null);
|
|
164
|
+
if (!declared) {
|
|
165
|
+
reject(pid, slotName, 'undeclared-slot',
|
|
166
|
+
`插件 ${pid} 没有在 plugin.json 的 slots 里声明「${slotName}」,拒绝注册`,
|
|
167
|
+
{ declared: declaredOf(pid).map((d) => d.name) });
|
|
168
|
+
return { ok: false };
|
|
169
|
+
}
|
|
170
|
+
// ⑤ 风险等级不符(目录为准;清单里的 risk 只作显式确认)
|
|
171
|
+
const declaredRisk = String(declared.risk || '').trim();
|
|
172
|
+
const catalogRisk = String(entry.risk || '').trim();
|
|
173
|
+
if (declaredRisk && catalogRisk && declaredRisk !== catalogRisk) {
|
|
174
|
+
reject(pid, slotName, 'risk-mismatch',
|
|
175
|
+
`插槽「${slotName}」风险等级不符:清单声明 ${declaredRisk},目录要求 ${catalogRisk}`,
|
|
176
|
+
{ catalogRisk, declaredRisk });
|
|
177
|
+
return { ok: false };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, entry, catalogRisk };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 校验 spec 形状:id 必填、必须带插件前缀(设计 §8.2)、同插槽内不许重名。
|
|
184
|
+
* @param {string} pid
|
|
185
|
+
* @param {string} slotName
|
|
186
|
+
* @param {object} spec
|
|
187
|
+
* @returns {{ok: true, id: string, order: number, label: string} | {ok: false}}
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* 键值插槽的目标控件校验:`panel.control.plugin.<pid>.<内置控件 id>`。
|
|
191
|
+
*
|
|
192
|
+
* 三条规则:
|
|
193
|
+
* ① 后缀(目标 id)不能空
|
|
194
|
+
* ② 目标必须是**既有内置控件** —— 键值插槽的语义是"替换",不是"新建"(新建归分组插槽)
|
|
195
|
+
* ③ 抢占按**目标控件**判,不是按 slot 名:两个插件的 key 天生不同,只有目标会撞
|
|
196
|
+
* @param {string} pid 插件 id
|
|
197
|
+
* @param {string} slotName 插槽名
|
|
198
|
+
* @param {string} prefix `plugin.<pid>.`
|
|
199
|
+
* @returns {{ok: true, target: string}|{ok: false}}
|
|
200
|
+
*/
|
|
201
|
+
/**
|
|
202
|
+
* `spec.setting` 校验:给了就必须是**该插件清单里声明过的**键,且带 `plugin.<pid>.` 前缀。
|
|
203
|
+
* 为什么要在注册期拦:写错一个键名会得到"控件能点、值写进了没人读的孤儿键",
|
|
204
|
+
* 表现为"改了没反应",比直接报错难查得多。
|
|
205
|
+
* @param {string} pid 插件 id
|
|
206
|
+
* @param {string} slotName 插槽名(只为日志)
|
|
207
|
+
* @param {string} setting 设置键
|
|
208
|
+
* @returns {boolean} 是否通过
|
|
209
|
+
*/
|
|
210
|
+
function checkSettingKey(pid, slotName, setting) {
|
|
211
|
+
const declaredKeys = new Set((settingsKeysOf(pid) || []).map(String));
|
|
212
|
+
if (!declaredKeys.has(setting)) {
|
|
213
|
+
reject(pid, slotName, 'undeclared-setting',
|
|
214
|
+
`spec.setting「${setting}」不在该插件清单的 settings 里声明过(已声明:${[...declaredKeys].join(' / ') || '无'})`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if (!setting.startsWith(`plugin.${pid}.`)) {
|
|
218
|
+
reject(pid, slotName, 'bad-setting-prefix',
|
|
219
|
+
`spec.setting「${setting}」必须以 plugin.${pid}. 开头(设计 §8.3:插件设置键带插件前缀)`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function checkKeyedTarget(pid, slotName, prefix, setting) {
|
|
226
|
+
const raw = slotName.slice('panel.control.'.length);
|
|
227
|
+
const tail = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
228
|
+
if (!tail) {
|
|
229
|
+
reject(pid, slotName, 'missing-target',
|
|
230
|
+
'键值插槽的目标控件 id 为空:应写成 panel.control.plugin.<pluginId>.<内置控件 id>');
|
|
231
|
+
return { ok: false };
|
|
232
|
+
}
|
|
233
|
+
if (!builtinControlIds.has(tail)) {
|
|
234
|
+
reject(pid, slotName, 'unknown-control',
|
|
235
|
+
`目标控件「${tail}」不在内置控件表里(替换只能指向既有控件;新建控件请用分组插槽 panel.group.*)`);
|
|
236
|
+
return { ok: false };
|
|
237
|
+
}
|
|
238
|
+
// 替换型控件**必须**带自己的 setting:值域与身份绑定,绝不与内置改动共用一个存储槽
|
|
239
|
+
// (共用会出现"存储旧值不在新值域 ⇒ 替换期间无选中项",那是根因层面的错配,不是显示小瑕疵)
|
|
240
|
+
if (!setting) {
|
|
241
|
+
reject(pid, slotName, 'missing-setting',
|
|
242
|
+
'替换型控件不得复用原生设置键,请声明自己的 setting(形如 plugin.<pluginId>.<key>,并在清单 settings 里声明);'
|
|
243
|
+
+ '纯动作按钮请改用 toolbar.actions 或分组插槽新增控件');
|
|
244
|
+
return { ok: false };
|
|
245
|
+
}
|
|
246
|
+
const holder = [...byPlugin.values()].flat().find((r) => String((r && r.target) || '') === tail);
|
|
247
|
+
if (holder) {
|
|
248
|
+
reject(pid, slotName, 'control-taken',
|
|
249
|
+
`内置控件「${tail}」已被插件「${holder.pluginId}」占位(后注册者被拒;先卸载或改用分组插槽新增控件)`);
|
|
250
|
+
return { ok: false };
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, target: tail };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function checkSpec(pid, slotName, spec) {
|
|
256
|
+
// spec.setting(可选):统一"控件存值键"与"清单设置键"(校验细节见 checkSettingKey)
|
|
257
|
+
const setting = String((spec && spec.setting) || '').trim();
|
|
258
|
+
if (setting && !checkSettingKey(pid, slotName, setting)) return { ok: false };
|
|
259
|
+
// 分组 id 规则(设计 §七·一):内置组直接用 id;插件新建分组必须带 plugin.<pluginId>. 前缀。
|
|
260
|
+
// 放在注册期而不是渲染期:渲染期被拒时控件"登记成功却不显示",插件作者会以为系统坏了。
|
|
261
|
+
const gm = /^panel\.group\.(.+)$/.exec(slotName);
|
|
262
|
+
if (gm) {
|
|
263
|
+
const gid = gm[1];
|
|
264
|
+
const isKnown = groupIds.has(gid);
|
|
265
|
+
const isPluginOwned = gid.startsWith(`plugin.${pid}.`) && gid.length > `plugin.${pid}.`.length;
|
|
266
|
+
if (!isKnown && !isPluginOwned) {
|
|
267
|
+
reject(pid, slotName, 'bad-group-id',
|
|
268
|
+
`分组「${gid}」不存在;新建分组的 id 必须写成 plugin.<pluginId>.<name>(当前插件 ${pid},应形如 plugin.${pid}.<name>)`);
|
|
269
|
+
return { ok: false };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const id = String((spec && spec.id) || '').trim();
|
|
273
|
+
if (!id) { reject(pid, slotName, 'missing-id', 'spec.id 必填(同插槽内唯一,用于回收与冲突提示)'); return { ok: false }; }
|
|
274
|
+
// 前缀强制只适用于**键值插槽**(设计 §8.2:panel.control.<key> 的 key 必须写成 plugin.<pluginId>.<key>)。
|
|
275
|
+
// 分组插槽(panel.group.*)里的控件 id 只需全局唯一 —— 它的归属已经由 pluginId 记录,
|
|
276
|
+
// 强行加前缀会把插件自己习惯的命名一脚踢开(验收夹具 zzfixture.amount 就是这么被误拒的)。
|
|
277
|
+
const isKeyed = /^panel\.control\./.test(slotName);
|
|
278
|
+
const prefix = `plugin.${pid}.`;
|
|
279
|
+
if (isKeyed && !id.startsWith(prefix)) {
|
|
280
|
+
reject(pid, slotName, 'bad-id-prefix', `键值插槽的 spec.id「${id}」必须带插件前缀 ${prefix}(设计 §8.2)`);
|
|
281
|
+
return { ok: false };
|
|
282
|
+
}
|
|
283
|
+
// 键值插槽:解析目标控件 + 抢占检测(细节见 checkKeyedTarget)
|
|
284
|
+
if (isKeyed) {
|
|
285
|
+
const keyed = checkKeyedTarget(pid, slotName, prefix, setting);
|
|
286
|
+
if (!keyed.ok) return { ok: false };
|
|
287
|
+
return { ok: true, id, setting, target: keyed.target };
|
|
288
|
+
}
|
|
289
|
+
if (listOf(pid).some((r) => r.name === slotName && r.id === id)) {
|
|
290
|
+
reject(pid, slotName, 'duplicate-id', `插槽「${slotName}」里已存在 id「${id}」`, { id });
|
|
291
|
+
return { ok: false };
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
id,
|
|
296
|
+
order: Number.isFinite(Number(spec && spec.order)) ? Number(spec.order) : 1000,
|
|
297
|
+
label: String((spec && spec.label) || id),
|
|
298
|
+
setting, // '' = 用 control id 存值(兼容既有插件)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 登记一个插槽(只登记,不渲染)。
|
|
304
|
+
* @param {string} pluginId 注册者(必须与清单里的 id 一致)
|
|
305
|
+
* @param {string} name 插槽名,如 `panel.group.settings`
|
|
306
|
+
* @param {object} spec 插件声明:{ id, order?, label?, render?, onAction? }
|
|
307
|
+
* @returns {null | (() => void)} 成功返回 disposer;失败返回 null(原因走 onReject + stats)
|
|
308
|
+
*/
|
|
309
|
+
function register(pluginId, name, spec, opts = {}) {
|
|
310
|
+
const pid = String(pluginId || '').trim();
|
|
311
|
+
const slotName = String(name || '').trim();
|
|
312
|
+
|
|
313
|
+
// ① 同步窗口:不在脚本装载期的调用一律拒(异步注册在 S1 就是明确不支持)
|
|
314
|
+
if (!isInLoadWindow()) {
|
|
315
|
+
reject(pid, slotName, 'not-in-load-window',
|
|
316
|
+
'不在脚本装载期:slots.register 必须在插件脚本顶层同步调用(await/Promise/setTimeout 之后的注册不被支持)');
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
if (!pid) { reject('', slotName, 'missing-plugin-id', '缺少 pluginId(宿主未提供装载上下文)'); return null; }
|
|
320
|
+
if (!slotName) { reject(pid, '', 'missing-slot-name', '缺少插槽名'); return null; }
|
|
321
|
+
|
|
322
|
+
const allowed = checkSlotAllowed(pid, slotName, opts);
|
|
323
|
+
if (!allowed.ok) return null;
|
|
324
|
+
const shape = checkSpec(pid, slotName, spec);
|
|
325
|
+
if (!shape.ok) return null;
|
|
326
|
+
|
|
327
|
+
const record = {
|
|
328
|
+
pluginId: pid, name: slotName, id: shape.id, order: shape.order, label: shape.label,
|
|
329
|
+
// 全局注册序号:单值插槽(panel.brand)靠它判"谁是最后注册的那个",
|
|
330
|
+
// 不能按 order(那是展示顺序)也不能按插件分组后的数组位置(跨插件会乱)
|
|
331
|
+
seq: (seqCounter += 1),
|
|
332
|
+
setting: shape.setting || '',
|
|
333
|
+
target: shape.target || '',
|
|
334
|
+
risk: allowed.catalogRisk, spec: spec || {},
|
|
335
|
+
// 兼容层来的登记留个标记:排查"这个控件哪来的"时能一眼分清
|
|
336
|
+
via: opts.implicitDeclare ? 'legacy-registerControls' : 'slots.register',
|
|
337
|
+
};
|
|
338
|
+
const list = byPlugin.get(pid) || [];
|
|
339
|
+
list.push(record);
|
|
340
|
+
byPlugin.set(pid, list);
|
|
341
|
+
|
|
342
|
+
let disposed = false;
|
|
343
|
+
return function disposeSlot() {
|
|
344
|
+
if (disposed) return; // 幂等:重复调用不会误删别人
|
|
345
|
+
disposed = true;
|
|
346
|
+
const cur = byPlugin.get(pid) || [];
|
|
347
|
+
const i = cur.indexOf(record);
|
|
348
|
+
if (i >= 0) cur.splice(i, 1);
|
|
349
|
+
if (!cur.length) byPlugin.delete(pid);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 回收某个插件的全部插槽登记(卸载插件时调;与逐个 disposer 等价,但更省事)。
|
|
355
|
+
* @param {string} pluginId
|
|
356
|
+
* @returns {number} 回收条数
|
|
357
|
+
*/
|
|
358
|
+
function disposeAll(pluginId) {
|
|
359
|
+
const pid = String(pluginId || '').trim();
|
|
360
|
+
const list = byPlugin.get(pid) || [];
|
|
361
|
+
byPlugin.delete(pid);
|
|
362
|
+
return list.length;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 快照(测试与 __SLOT_STATS__ 读它) */
|
|
366
|
+
function stats() {
|
|
367
|
+
const plugins = {};
|
|
368
|
+
let total = 0;
|
|
369
|
+
for (const [pid, list] of byPlugin) {
|
|
370
|
+
plugins[pid] = list.length;
|
|
371
|
+
total += list.length;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
openStage,
|
|
375
|
+
catalogSize: entries.length,
|
|
376
|
+
total,
|
|
377
|
+
byPlugin: plugins,
|
|
378
|
+
// 明细保留必要字段即可:spec 里可能有函数,不能直接序列化给测试
|
|
379
|
+
entries: [...byPlugin].flatMap(([pid, list]) => list.map((r) => ({
|
|
380
|
+
pluginId: pid, name: r.name, id: r.id, order: r.order, label: r.label, risk: r.risk,
|
|
381
|
+
setting: r.setting || '', seq: r.seq || 0, target: r.target || '', via: r.via || 'slots.register',
|
|
382
|
+
}))),
|
|
383
|
+
rejects: rejects.slice(),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 供**渲染层**读取的登记明细(含 spec,能拿到 render 函数)。
|
|
389
|
+
*
|
|
390
|
+
* 与 stats() 的区别:stats() 是给测试/__SLOT_STATS__ 的可序列化快照(不含函数),
|
|
391
|
+
* 这里是装配期真正要拿去渲染的对象;两者共用一个真相(byPlugin),只是视图不同。
|
|
392
|
+
* @returns {Array<{pluginId:string,name:string,id:string,order:number,label:string,risk:string,spec:object}>}
|
|
393
|
+
*/
|
|
394
|
+
function renderEntries() {
|
|
395
|
+
return [...byPlugin].flatMap(([pid, list]) => list.map((r) => ({
|
|
396
|
+
pluginId: pid, name: r.name, id: r.id, order: r.order, label: r.label, risk: r.risk,
|
|
397
|
+
setting: r.setting || '', target: r.target || '', seq: r.seq || 0, spec: r.spec || {},
|
|
398
|
+
})));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { register, disposeAll, stats, renderEntries };
|
|
402
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// vfs.js —— 虚拟文件系统(VirtualFS):内存资源成为一等公民
|
|
2
|
+
//
|
|
3
|
+
// 做什么:插件把内存里的文件(Blob / 字符串)按「前缀 + 相对路径」注册进来,
|
|
4
|
+
// 加载器拿原始 URL 来问 resolve(),命中就返回一个可用的 Blob URL,未命中返回 null。
|
|
5
|
+
// 为什么需要:Live2D / 资源包等插件的资源常常不是磁盘文件,以前只能 patch 加载器;
|
|
6
|
+
// 有了 VirtualFS,插件只注册文件,加载器保持原样(未命中回落原 URL,天然可退化)。
|
|
7
|
+
//
|
|
8
|
+
// 设计约束:
|
|
9
|
+
// · 纯模块:不碰 DOM、不 import 任何模块,可在 Node 里直接单测;
|
|
10
|
+
// · Blob URL **懒创建 + 缓存**:第一次 resolve 才建,重复 resolve 复用同一个;
|
|
11
|
+
// · revoke(prefix) 释放该前缀下所有 Blob URL 并注销该前缀(之后 resolve 返回 null);
|
|
12
|
+
// · prefix 全局唯一:重复注册直接抛错,避免"谁覆盖谁"的隐性 bug;
|
|
13
|
+
// · 路径归一化统一处理 `./`、`../`、查询串、哈希、URL 编码、反斜杠。
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 把任意写法的路径归一化成「无前导斜杠、无 ./、无 ../、无查询串」的相对路径。
|
|
17
|
+
* @param {string} raw
|
|
18
|
+
* @returns {string|null} 归一化结果;无法解析时返回 null
|
|
19
|
+
*/
|
|
20
|
+
function normalizePath(raw) {
|
|
21
|
+
if (typeof raw !== 'string' || !raw) return null;
|
|
22
|
+
let p = raw.split('#')[0].split('?')[0];
|
|
23
|
+
// 编码可能套了两层(例如 %2520),最多解两层,解不开就保持原样
|
|
24
|
+
for (let i = 0; i < 2; i += 1) {
|
|
25
|
+
try {
|
|
26
|
+
const decoded = decodeURIComponent(p);
|
|
27
|
+
if (decoded === p) break;
|
|
28
|
+
p = decoded;
|
|
29
|
+
} catch {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
p = p.replace(/\\/g, '/');
|
|
34
|
+
const parts = [];
|
|
35
|
+
for (const seg of p.split('/')) {
|
|
36
|
+
if (!seg || seg === '.') continue;
|
|
37
|
+
if (seg === '..') { parts.pop(); continue; }
|
|
38
|
+
parts.push(seg);
|
|
39
|
+
}
|
|
40
|
+
return parts.join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 取 URL 里可参与匹配的部分。
|
|
45
|
+
*
|
|
46
|
+
* http(s) 取 pathname(真实网络资源);
|
|
47
|
+
* **自定义 scheme(vfs://…)整体按路径归一化** —— 它本身就是"路径标识",不是网络地址。
|
|
48
|
+
* 早期一律走 new URL():`vfs://prov/root/a.json` 会被拆成 host=prov + pathname=/root/a.json,
|
|
49
|
+
* 于是与注册时归一化出的 `vfs:/prov/root` 前缀永远对不上,vfs:// 路径全部 resolve 失败。
|
|
50
|
+
* @param {string} url
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
function pathOfUrl(url) {
|
|
54
|
+
const text = String(url == null ? '' : url);
|
|
55
|
+
if (!text) return null;
|
|
56
|
+
const customScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) && !/^https?:\/\//i.test(text);
|
|
57
|
+
if (customScheme) return normalizePath(text);
|
|
58
|
+
try {
|
|
59
|
+
const parsed = new URL(text, 'http://vfs.local/');
|
|
60
|
+
return normalizePath(parsed.pathname);
|
|
61
|
+
} catch {
|
|
62
|
+
return normalizePath(text);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 字节数:Blob 用 size,字符串按 UTF-8 估算 */
|
|
67
|
+
function sizeOf(value) {
|
|
68
|
+
if (typeof value === 'string') {
|
|
69
|
+
if (typeof TextEncoder === 'function') return new TextEncoder().encode(value).length;
|
|
70
|
+
return value.length;
|
|
71
|
+
}
|
|
72
|
+
if (value && typeof value.size === 'number') return value.size;
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 创建虚拟文件系统。
|
|
78
|
+
* @param {{
|
|
79
|
+
* createObjectURL?: (blob: Blob) => string,
|
|
80
|
+
* revokeObjectURL?: (url: string) => void,
|
|
81
|
+
* }} [options] 可注入 URL 工厂(Node 环境没有 URL.createObjectURL 时用 fake 单测)
|
|
82
|
+
*/
|
|
83
|
+
export function createVirtualFS(options = {}) {
|
|
84
|
+
const createObjectURL = typeof options.createObjectURL === 'function'
|
|
85
|
+
? options.createObjectURL
|
|
86
|
+
: (blob) => URL.createObjectURL(blob);
|
|
87
|
+
const revokeObjectURL = typeof options.revokeObjectURL === 'function'
|
|
88
|
+
? options.revokeObjectURL
|
|
89
|
+
: (url) => URL.revokeObjectURL(url);
|
|
90
|
+
|
|
91
|
+
/** prefix → { files: Map<relPath, Blob|string>, urls: Map<relPath, blobUrl> } */
|
|
92
|
+
const entries = new Map();
|
|
93
|
+
let hits = 0;
|
|
94
|
+
let misses = 0;
|
|
95
|
+
let createdUrls = 0;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 注册一批文件。
|
|
99
|
+
* @param {string} prefix 前缀(全局唯一),如 `/live2d-lib/` 或 `atlas/`
|
|
100
|
+
* @param {Map<string, Blob|string>|Record<string, Blob|string>} files 相对路径 → 内容
|
|
101
|
+
*/
|
|
102
|
+
function register(prefix, files) {
|
|
103
|
+
const key = normalizePath(prefix);
|
|
104
|
+
if (!key) throw new Error('VirtualFS.register: prefix 不能为空');
|
|
105
|
+
if (entries.has(key)) throw new Error(`VirtualFS.register: prefix 已注册:${prefix}`);
|
|
106
|
+
|
|
107
|
+
const src = files instanceof Map ? files : new Map(Object.entries(files || {}));
|
|
108
|
+
const normalized = new Map();
|
|
109
|
+
for (const [rel, value] of src) {
|
|
110
|
+
const p = normalizePath(rel);
|
|
111
|
+
if (!p) throw new Error(`VirtualFS.register: 非法相对路径:${rel}`);
|
|
112
|
+
if (typeof value !== 'string' && !(value && typeof value === 'object' && typeof value.size === 'number')) {
|
|
113
|
+
throw new Error(`VirtualFS.register: 内容必须是 Blob 或字符串:${rel}`);
|
|
114
|
+
}
|
|
115
|
+
normalized.set(p, value);
|
|
116
|
+
}
|
|
117
|
+
entries.set(key, { files: normalized, urls: new Map() });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 某前缀下已注册多少个文件(**只读诊断接口**;0 = 这个前缀压根没注册过)。
|
|
122
|
+
*
|
|
123
|
+
* 为什么需要它:`stats()` 给的是**全部前缀的合计**,看不出"我请求的这个前缀下一个文件都没有"
|
|
124
|
+
* 这个关键区别 —— 而那正是"prefix 对不上(产物里存的是旧 prefix)"与
|
|
125
|
+
* "这个包真的缺这个文件"两种故障的分水岭。没有它,排查只能靠猜。
|
|
126
|
+
* 不改任何行为:只查表,不注册、不撤销、不创建 Blob、不计命中/未命中。
|
|
127
|
+
* @param {string} prefix 原始写法(内部按与 register 相同的规则归一化)
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function prefixFileCount(prefix) {
|
|
131
|
+
const key = normalizePath(prefix);
|
|
132
|
+
if (!key) return 0;
|
|
133
|
+
const entry = entries.get(key);
|
|
134
|
+
return entry && entry.files ? entry.files.size : 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 懒创建并缓存某文件的 Blob URL */
|
|
138
|
+
function urlFor(prefix, rel, value) {
|
|
139
|
+
const entry = entries.get(prefix);
|
|
140
|
+
const cached = entry.urls.get(rel);
|
|
141
|
+
if (cached) return cached;
|
|
142
|
+
const blob = typeof value === 'string' ? new Blob([value]) : value;
|
|
143
|
+
const url = createObjectURL(blob);
|
|
144
|
+
entry.urls.set(rel, url);
|
|
145
|
+
createdUrls += 1;
|
|
146
|
+
return url;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把原始 URL 解析成可用地址。
|
|
151
|
+
* @param {string} url
|
|
152
|
+
* @returns {string|null} 命中返回 Blob URL;未命中返回 null(调用方回落原 URL)
|
|
153
|
+
*/
|
|
154
|
+
function resolve(url) {
|
|
155
|
+
const target = pathOfUrl(url);
|
|
156
|
+
if (!target) { misses += 1; return null; }
|
|
157
|
+
for (const [prefix, entry] of entries) {
|
|
158
|
+
if (target !== prefix && !target.startsWith(`${prefix}/`)) continue;
|
|
159
|
+
const rel = target === prefix ? '' : target.slice(prefix.length + 1);
|
|
160
|
+
const value = entry.files.get(rel);
|
|
161
|
+
if (value === undefined) continue;
|
|
162
|
+
hits += 1;
|
|
163
|
+
return urlFor(prefix, rel, value);
|
|
164
|
+
}
|
|
165
|
+
misses += 1;
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 释放某个前缀下的全部 Blob URL 并注销该前缀(之后 resolve 返回 null)。
|
|
171
|
+
* @param {string} prefix
|
|
172
|
+
* @returns {number} 释放的 URL 数量
|
|
173
|
+
*/
|
|
174
|
+
function revoke(prefix) {
|
|
175
|
+
const key = normalizePath(prefix);
|
|
176
|
+
const entry = key ? entries.get(key) : null;
|
|
177
|
+
if (!entry) return 0;
|
|
178
|
+
let count = 0;
|
|
179
|
+
for (const url of entry.urls.values()) {
|
|
180
|
+
try { revokeObjectURL(url); count += 1; } catch { /* 释放失败继续 */ }
|
|
181
|
+
}
|
|
182
|
+
entry.urls.clear();
|
|
183
|
+
entries.delete(key);
|
|
184
|
+
return count;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 统计:前缀数、文件数、字节数、命中/未命中次数(+ 已创建 URL 数,便于验证懒创建)。
|
|
189
|
+
*/
|
|
190
|
+
function stats() {
|
|
191
|
+
let files = 0;
|
|
192
|
+
let bytes = 0;
|
|
193
|
+
for (const entry of entries.values()) {
|
|
194
|
+
files += entry.files.size;
|
|
195
|
+
for (const value of entry.files.values()) bytes += sizeOf(value);
|
|
196
|
+
}
|
|
197
|
+
return { prefixes: entries.size, files, bytes, hits, misses, urls: createdUrls };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 列出某个前缀下已注册的**相对路径**(升序)。
|
|
202
|
+
*
|
|
203
|
+
* 与 resolve 的区别:resolve 按完整 VFS 地址取**内容**,这里只要**名字**——
|
|
204
|
+
* 面板 owner props 的 document.files 就是它(插件拿名字做判断,不需要字节)。
|
|
205
|
+
* 返回 null 表示"这个前缀还没注册"(≠ 已注册但为空数组):
|
|
206
|
+
* 调用方要能区分"还没解包完"与"确实没有文件",否则会给出误导性的空清单。
|
|
207
|
+
* @param {string} prefix 前缀(如 `vfs://xxx/`)
|
|
208
|
+
* @returns {string[]|null}
|
|
209
|
+
*/
|
|
210
|
+
function list(prefix) {
|
|
211
|
+
const key = normalizePath(prefix);
|
|
212
|
+
const entry = key ? entries.get(key) : null;
|
|
213
|
+
if (!entry) return null;
|
|
214
|
+
return [...entry.files.keys()].sort();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { register, resolve, revoke, stats, prefixFileCount, list };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 进程内共享实例(懒创建、幂等)。
|
|
222
|
+
*
|
|
223
|
+
* 为什么需要它:VirtualFS 是"注册方(插件/测试)"与"消费方(渲染器)"之间的唯一通道,
|
|
224
|
+
* 双方必须拿到**同一个实例** —— 各建一个的话 register 完也解析不到,表现为"接了但没生效"。
|
|
225
|
+
* 同时挂到 window.__VFS__ 作为诊断/验收入口(与 window.__DIAG__ 同一模式):
|
|
226
|
+
* 便于在页面里手工 register 一段内存资源,再用 vfs:// 路径装载做端到端验证。
|
|
227
|
+
* @returns {ReturnType<typeof createVirtualFS>}
|
|
228
|
+
*/
|
|
229
|
+
let sharedInstance = null;
|
|
230
|
+
export function getSharedVirtualFS() {
|
|
231
|
+
if (!sharedInstance) {
|
|
232
|
+
sharedInstance = createVirtualFS();
|
|
233
|
+
try {
|
|
234
|
+
if (typeof window !== 'undefined' && !window.__VFS__) window.__VFS__ = sharedInstance;
|
|
235
|
+
} catch { /* 只读环境忽略 */ }
|
|
236
|
+
}
|
|
237
|
+
return sharedInstance;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default createVirtualFS;
|
|
241
|
+
|
|
242
|
+
// 诊断入口立刻可用:不等第一次资源解析。
|
|
243
|
+
// 为什么:验收入口/排查时需要在**加载之前**就能 register 一段内存资源,
|
|
244
|
+
// 懒创建会让"先 register 再装载"的顺序做不出来(表现为 __VFS__ 是 undefined)。
|
|
245
|
+
if (typeof window !== 'undefined') {
|
|
246
|
+
try { if (!window.__VFS__) window.__VFS__ = getSharedVirtualFS(); } catch { /* 只读环境忽略 */ }
|
|
247
|
+
}
|