From 34d51dd711fd59960fa88a3b7d345ef873bdbf05 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 14:05:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(4.0-P6):=20=E5=B5=8C=E5=A5=97=E7=A7=98?= =?UTF-8?q?=E5=AF=86=E5=AD=97=E6=AE=B5=E6=8E=A9=E7=A0=81=E3=80=81=E6=9C=AA?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E6=8F=92=E4=BB=B6=20schema=20=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E3=80=81custom=20element=20=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/module/api/config.py | 5 +- backend/src/module/plugin/manager.py | 24 +++- backend/src/module/plugin/manifest.py | 20 +++- backend/src/module/plugin/secrets.py | 109 ++++++++++++++---- backend/src/test/test_plugin_extensions.py | 85 ++++++++++++++ backend/src/test/test_plugin_runtime.py | 57 ++++++++- backend/src/test/test_plugin_web.py | 16 +++ ...26-10-08-plugin-architecture-4.0-design.md | 8 ++ .../services/__tests__/plugin-loader.test.ts | 33 ++++++ webui/src/services/plugin-loader.ts | 25 ++++ 10 files changed, 350 insertions(+), 32 deletions(-) diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index 196e203a..bfe23f89 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -158,8 +158,9 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)): current = settings.dict() schemas = _plugin_schemas(ctx) current_options = current.get("plugins", {}).get("options", {}) - for plugin_id, plugin_options in config_dict["plugins"]["options"].items(): - restore_options( + options = config_dict["plugins"]["options"] + for plugin_id, plugin_options in options.items(): + options[plugin_id] = restore_options( plugin_options, current_options.get(plugin_id, {}), schemas.get(plugin_id), diff --git a/backend/src/module/plugin/manager.py b/backend/src/module/plugin/manager.py index 8e428751..9539c8ec 100644 --- a/backend/src/module/plugin/manager.py +++ b/backend/src/module/plugin/manager.py @@ -17,7 +17,7 @@ from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR from .bus import EventBus from .context import PLUGIN_DATA_ROOT, HostPluginContext from .loader import DiscoveryError, PluginCandidate, PluginLoadError, discover -from .manifest import PluginUi +from .manifest import PluginUi, owns_element from .registry import ExtensionRegistry, HookEntry, ProviderEntry from .runner import CircuitBreaker, HookRunner @@ -171,8 +171,17 @@ class PluginManager: (plugin_id, ui) for plugin_id, active in sorted(self._active.items()) for ui in active.candidate.manifest.ui + if self._element_owner(ui.element) == plugin_id ] + def _element_owner(self, element: str) -> str | None: + """custom element 名的归属:命名空间匹配的插件里 id 最长者。 + + ``foo`` 的命名空间前缀也匹配 ``ab-plugin-foo-bar``,该名字应归 ``foo-bar``。 + 按全部已发现的插件判定,启停插件不会改变归属。""" + owners = [i for i in self._candidates if owns_element(i, element)] + return max(owners, key=len, default=None) + def web_dir(self, plugin_id: str) -> Path | None: """已启用插件的 ``web/`` 目录;未启用或没有该目录时为 None。""" active = self._active.get(plugin_id) @@ -200,10 +209,11 @@ class PluginManager: def _probe_schemas(self) -> None: """未加载过的可信插件:只导入代码取 config_model,让设置表单在首次启用 前就可用。未签名插件须先开启 allow_unsigned;导入失败记为无 schema, - 真正启用时会再次加载并报告原因。""" + 真正启用时会再次加载并报告原因。每次重新发现后都重新取,插件目录升级后 + 表单随之更新,首次失败也不会一直缓存。""" conf = self._settings.plugins for plugin_id, candidate in self._candidates.items(): - if plugin_id in self._models or plugin_id in self._active: + if plugin_id in self._active: continue if not (candidate.signed or conf.allow_unsigned): continue @@ -221,6 +231,14 @@ class PluginManager: # 清单错误已在 discover() 中记录日志 candidates, _ = self._discover() self._candidates = {c.manifest.id: c for c in candidates} + for plugin_id, candidate in self._candidates.items(): + for ui in candidate.manifest.ui: + if self._element_owner(ui.element) != plugin_id: + logger.warning( + "[Plugin:%s] 元素名 %s 属于更长 id 的插件,挂载点已忽略", + plugin_id, + ui.element, + ) for plugin_id in [p for p in self._failed if p not in self._candidates]: del self._failed[plugin_id] diff --git a/backend/src/module/plugin/manifest.py b/backend/src/module/plugin/manifest.py index dc20f1a3..66347c13 100644 --- a/backend/src/module/plugin/manifest.py +++ b/backend/src/module/plugin/manifest.py @@ -7,7 +7,7 @@ from typing import Literal from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from ab_sdk import SDK_VERSION @@ -29,6 +29,12 @@ UiSlot = Literal[ ] +def owns_element(plugin_id: str, element: str) -> bool: + """element 是否落在 ``ab-plugin-`` 命名空间内。""" + prefix = f"ab-plugin-{plugin_id}" + return element == prefix or element.startswith(prefix + "-") + + class ManifestError(ValueError): pass @@ -111,6 +117,18 @@ class PluginManifest(BaseModel): raise ValueError("entry 须形如 'my_plugin:MyPlugin'") return value + @model_validator(mode="after") + def _check_ui_elements(self) -> "PluginManifest": + # custom element 名是全局的:限定在 ab-plugin- 命名空间内, + # 一个插件才不会占用别的插件的元素名 + for ui in self.ui: + if not owns_element(self.id, ui.element): + raise ValueError( + f"element {ui.element!r} 须为 ab-plugin-{self.id} " + f"或以 ab-plugin-{self.id}- 开头" + ) + return self + @property def entry_module(self) -> str: return self.entry.split(":", 1)[0] diff --git a/backend/src/module/plugin/secrets.py b/backend/src/module/plugin/secrets.py index 851b6171..e3daab31 100644 --- a/backend/src/module/plugin/secrets.py +++ b/backend/src/module/plugin/secrets.py @@ -3,6 +3,9 @@ 插件用 ``ab_sdk.secret_field()`` 声明秘密字段(JSON Schema 中带 ``secret`` 标记,或 ``format: password`` / ``writeOnly``)。读接口返回掩码,写接口收到 掩码时还原为已保存的值,与 /config 对密码类字段的处理一致。 + +秘密字段可以出现在嵌套对象、对象数组和字典值里。数组行没有稳定 id,还原时 +先按「掩码后内容相同」把回传行对应到已保存的行,因此删除、调换行不会串用密码。 """ from typing import Any @@ -10,39 +13,97 @@ from typing import Any MASK = "********" -def secret_keys(schema: dict[str, Any] | None) -> set[str]: - props = (schema or {}).get("properties", {}) - return { - key - for key, prop in props.items() - if prop.get("secret") - or prop.get("writeOnly") - or prop.get("format") == "password" - } +# 无 schema(插件代码本进程未加载过)时无法区分秘密字段:所有字符串都按秘密处理 +_ALL: dict[str, Any] = {"secret": True} +_ALL["additionalProperties"] = _ALL["items"] = _ALL +_MISSING = object() -def _keys(options: dict[str, Any], schema: dict[str, Any] | None) -> set[str]: - # 插件代码本进程未加载过时没有 schema,无法区分秘密字段:全部按秘密处理 - return set(options) if schema is None else secret_keys(schema) +def _branches(schema: dict[str, Any], root: dict[str, Any]) -> list[dict[str, Any]]: + """展开 $ref 与 anyOf / oneOf(如 ``Server | None``)后的候选分支。""" + while "$ref" in schema: + schema = root["$defs"][schema["$ref"].rsplit("/", 1)[-1]] + subs = schema.get("anyOf", []) + schema.get("oneOf", []) + return [b for s in subs for b in _branches(s, root)] if subs else [schema] + + +def _is_secret(branches: list[dict[str, Any]]) -> bool: + return any( + b.get("secret") or b.get("writeOnly") or b.get("format") == "password" + for b in branches + ) + + +def _child(branches: list[dict[str, Any]], key: str) -> dict[str, Any]: + subs = [ + b.get("properties", {}).get(key, b.get("additionalProperties")) + for b in branches + ] + return {"anyOf": [s for s in subs if isinstance(s, dict)]} + + +def _items(branches: list[dict[str, Any]]) -> dict[str, Any]: + subs = [b.get("items") for b in branches] + return {"anyOf": [s for s in subs if isinstance(s, dict)]} + + +def _mask(value: Any, schema: dict[str, Any], root: dict[str, Any]) -> Any: + branches = _branches(schema, root) + if isinstance(value, str): + return MASK if value and _is_secret(branches) else value + if isinstance(value, dict): + return {k: _mask(v, _child(branches, k), root) for k, v in value.items()} + if isinstance(value, list): + return [_mask(v, _items(branches), root) for v in value] + return value + + +def _restore( + value: Any, current: Any, schema: dict[str, Any], root: dict[str, Any] +) -> Any: + """返回还原后的值;掩码值没有可还原的已保存值时返回 ``_MISSING``(丢弃)。""" + branches = _branches(schema, root) + if isinstance(value, str): + if value == MASK and _is_secret(branches): + return current if isinstance(current, str) else _MISSING + return value + if isinstance(value, dict): + saved = current if isinstance(current, dict) else {} + restored = { + k: _restore(v, saved.get(k), _child(branches, k), root) + for k, v in value.items() + } + return {k: v for k, v in restored.items() if v is not _MISSING} + if isinstance(value, list): + saved_rows = current if isinstance(current, list) else [] + item = _items(branches) + masked = [_mask(c, item, root) for c in saved_rows] + rows: list[Any] = [] + for i, row in enumerate(value): + if i < len(masked) and masked[i] == row: + j = i + elif row in masked: + j = masked.index(row) + elif len(value) == len(saved_rows): + j = i # 行内容也改过且行数未变:按位置对应 + else: + j = -1 # 无法确定对应行:宁可丢弃掩码,不串用别行的密码 + r = _restore(row, saved_rows[j] if j >= 0 else None, item, root) + if r is not _MISSING: + rows.append(r) + return rows + return value def mask_options( options: dict[str, Any], schema: dict[str, Any] | None ) -> dict[str, Any]: - masked = dict(options) - for key in _keys(options, schema): - if isinstance(masked.get(key), str) and masked[key]: - masked[key] = MASK - return masked + return _mask(options, _ALL if schema is None else schema, schema or _ALL) def restore_options( incoming: dict[str, Any], current: dict[str, Any], schema: dict[str, Any] | None ) -> dict[str, Any]: - for key in _keys(incoming, schema): - if incoming.get(key) == MASK: - if key in current: - incoming[key] = current[key] - else: - incoming.pop(key) - return incoming + return _restore( + incoming, current, _ALL if schema is None else schema, schema or _ALL + ) diff --git a/backend/src/test/test_plugin_extensions.py b/backend/src/test/test_plugin_extensions.py index 7ade3b4a..df6df807 100644 --- a/backend/src/test/test_plugin_extensions.py +++ b/backend/src/test/test_plugin_extensions.py @@ -454,6 +454,91 @@ class TestSecrets: assert restored == {"site": "a", "cookie": "c=1", "n": 3} +class Server(BaseModel): + host: str = "" + password: str = secret_field() + + +class NestedOptions(BaseModel): + servers: list[Server] = [] + primary: Server | None = None + by_name: dict[str, Server] = {} + + +NESTED = NestedOptions.model_json_schema() + + +class TestNestedSecrets: + def test_mask_options_nested_secrets_masked(self): + options = { + "servers": [{"host": "a", "password": "p1"}, {"host": "b", "password": ""}], + "primary": {"host": "c", "password": "p3"}, + "by_name": {"x": {"host": "d", "password": "p4"}}, + } + assert mask_options(options, NESTED) == { + "servers": [ + {"host": "a", "password": MASK}, + {"host": "b", "password": ""}, + ], + "primary": {"host": "c", "password": MASK}, + "by_name": {"x": {"host": "d", "password": MASK}}, + } + + def test_mask_options_nested_without_schema_masks_all_strings(self): + options = {"servers": [{"host": "a", "n": 1}], "k": {"v": "s"}} + assert mask_options(options, None) == { + "servers": [{"host": MASK, "n": 1}], + "k": {"v": MASK}, + } + + @pytest.mark.parametrize( + "incoming, expected", + [ + # 原样回传:每行各自还原 + ( + [("a", MASK), ("b", MASK)], + [("a", "p1"), ("b", "p2")], + ), + # 删除第一行:第二行仍取回自己的密码 + ([("b", MASK)], [("b", "p2")]), + # 调换顺序 + ([("b", MASK), ("a", MASK)], [("b", "p2"), ("a", "p1")]), + # 新增行带明文;修改过的密码不被还原 + ([("a", MASK), ("c", "new")], [("a", "p1"), ("c", "new")]), + # 行数未变、第二行改了主机:按位置还原 + ([("a", MASK), ("c", MASK)], [("a", "p1"), ("c", "p2")]), + # 行数变了又改过内容:无法对应,丢弃该字段而不是取别行的密码 + ([("c", MASK)], [("c", None)]), + ], + ) + def test_restore_options_list_rows_restored_by_content(self, incoming, expected): + current = { + "servers": [ + {"host": "a", "password": "p1"}, + {"host": "b", "password": "p2"}, + ] + } + body = {"servers": [{"host": h, "password": p} for h, p in incoming]} + restored = restore_options(body, current, NESTED) + assert restored["servers"] == [ + {"host": h} if p is None else {"host": h, "password": p} + for h, p in expected + ] + + def test_restore_options_nested_object_and_map_restored(self): + current = { + "primary": {"host": "c", "password": "p3"}, + "by_name": {"x": {"host": "d", "password": "p4"}}, + } + masked = mask_options(current, NESTED) + assert restore_options(masked, current, NESTED) == current + + def test_restore_options_nested_without_schema_restored(self): + current = {"servers": [{"host": "a", "n": 1}]} + masked = mask_options(current, None) + assert restore_options(masked, current, None) == current + + # ---------------------------------------------------------------- API diff --git a/backend/src/test/test_plugin_runtime.py b/backend/src/test/test_plugin_runtime.py index 58f75333..051cb89c 100644 --- a/backend/src/test/test_plugin_runtime.py +++ b/backend/src/test/test_plugin_runtime.py @@ -105,7 +105,9 @@ class TestManifest: parse_manifest(text) def test_ui_entries_parsed(self): - m = parse_manifest(MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI) + m = parse_manifest( + MANIFEST.format(id="manual-pick", sdk=">=0.1", entry="a:B") + UI + ) [ui] = m.ui assert (ui.slot, ui.element, ui.entry) == ( "bangumi.detail.tab", @@ -131,10 +133,34 @@ class TestManifest: ], ) def test_invalid_ui_entry(self, old, new, message): - text = MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI.replace(old, new) + text = MANIFEST.format(id="manual-pick", sdk=">=0.1", entry="a:B") + UI.replace( + old, new + ) with pytest.raises(ManifestError, match=message): parse_manifest(text) + @pytest.mark.parametrize( + "plugin_id, element, valid", + [ + ("manual-pick", "ab-plugin-manual-pick", True), + ("manual-pick", "ab-plugin-manual-pick-panel", True), + # 元素名须落在本插件 id 的命名空间内,不能占用别的插件的名字 + ("other", "ab-plugin-manual-pick", False), + ("manual", "ab-plugin-manualpick", False), + ], + ) + def test_ui_element_must_be_namespaced_by_plugin_id( + self, plugin_id, element, valid + ): + text = MANIFEST.format(id=plugin_id, sdk=">=0.1", entry="a:B") + UI.replace( + "ab-plugin-manual-pick", element + ) + if valid: + assert parse_manifest(text).ui[0].element == element + else: + with pytest.raises(ManifestError, match=f"ab-plugin-{plugin_id}"): + parse_manifest(text) + # ---------------------------------------------------------------- registry @@ -673,6 +699,33 @@ class TestManager: with pytest.raises(ValidationError): manager.validate_options("demo", {"min_size": "x"}) + @pytest.mark.parametrize("first_probe_fails", [False, True]) + async def test_disabled_plugin_schema_refreshed_on_reload( + self, tmp_path, first_probe_fails + ): + from pydantic import BaseModel + + class NewOptions(BaseModel): + level: int = 1 + + def broken(): + raise RuntimeError("boom") + + old = build_plugin(Recorder()) + candidates = [candidate_for(old, default_enabled=False)] + if first_probe_fails: + candidates[0].load = broken # type: ignore[method-assign] + manager, _ = make_manager(*candidates, tmp_path=tmp_path) + await manager.start() + assert (manager.statuses()[0].config_schema is None) == first_probe_fails + + # 升级插件目录后重新发现:换成 config_model 不同的新类 + new = type("NewPlugin", (Plugin,), {"config_model": NewOptions}) + manager._discover = lambda: ([candidate_for(new, default_enabled=False)], []) + await manager.apply_settings() + [status] = manager.statuses() + assert list(status.config_schema["properties"]) == ["level"] # type: ignore[index] + @pytest.mark.parametrize( "conf, sdk, message", [ diff --git a/backend/src/test/test_plugin_web.py b/backend/src/test/test_plugin_web.py index fe6203bd..e5755b31 100644 --- a/backend/src/test/test_plugin_web.py +++ b/backend/src/test/test_plugin_web.py @@ -92,6 +92,22 @@ class TestManager: assert manager.ui_slots() == [] assert manager.web_dir("demo") is None + async def test_ui_slots_element_owned_by_longest_matching_plugin_id(self, tmp_path): + # 「foo」的命名空间前缀也匹配 ab-plugin-foo-bar,但该名字属于更长的 id「foo-bar」 + foo = candidate(tmp_path, "foo") + foo.manifest.ui[0].element = "ab-plugin-foo-bar" + foo_bar = candidate(tmp_path, "foo-bar") + mgr = PluginManager( + SimpleNamespace(plugins=Plugins()), + discover_fn=lambda: ([foo, foo_bar], []), + data_root=tmp_path / "data", + ) + await mgr.start() + assert [(pid, ui.element) for pid, ui in mgr.ui_slots()] == [ + ("foo-bar", "ab-plugin-foo-bar") + ] + await mgr.stop() + class TestApi: def test_list_ui_slots(self, client): diff --git a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md index 53188d7e..1826f724 100644 --- a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -187,6 +187,14 @@ slots 解析: - **验证**:除 vitest / pytest 外,用 `vite preview`(带 CSP)加真实后端、系统 Chrome 无头跑过:详情页标签列出种子并选用、选择经 SSE `bus` 帧刷新、设置页补链按钮返回计数、`page` / `bangumi.card.action` 挂载点、崩溃与未定义元素显示失败提示,页面未报 CSP 违例。 - 踩坑:UnoCSS 的 attributify 会把源码里的 `` `[plugin:${id}]` `` 当成样式规则并让构建失败;`plugin-slot.vue` 里的日志不要用这种写法。 +### 实施中的调整(P6 评审修复) + +- **秘密字段掩码覆盖嵌套结构**(`module/plugin/secrets.py`):`mask_options` / `restore_options` 沿 JSON Schema 递归(`$ref`、`anyOf` / `oneOf`、对象、数组 `items`、字典 `additionalProperties`),对象数组行里的 `secret_field()` 也会掩码。无 schema 时仍把全部字符串按秘密处理,现在同样递归。`secret_keys()` 无其它调用方,已删除;`/config/update` 改为使用返回值。 +- **数组行没有稳定 id,还原按「掩码后内容相同」对应已保存的行**:删除、调换行不会串用密码。行内容也被改过时,行数未变则按位置对应;行数已变则无法确定,丢弃该掩码字段(密码留空,由插件的校验或用户重新输入处理),不取别行的密码。 +- **未启用插件的 schema 每次重新发现后重新读取**(`_probe_schemas`):插件目录升级后表单随之更新,首次导入失败也不再一直缓存为「无 schema」。代价是每次 `apply_settings` 会重新导入未启用的可信插件一次。 +- **custom element 名归插件所有**:清单要求 `element` 为 `ab-plugin-` 或以 `ab-plugin--` 开头。id 含连字符时命名空间会重叠(`foo` 的前缀也匹配 `ab-plugin-foo-bar`),`PluginManager.ui_slots()` 按全部已发现插件取 id 最长者为归属,其余声明被忽略并在重新发现时写警告日志。 +- **前端按定义者校验元素**(`services/plugin-loader.ts`):加载器包装 `customElements.define`,按调用栈里最近的 `/plugins//web/` 脚本地址记录每个名字由哪个插件定义;挂载点发现元素由其它插件定义时拒绝实例化并显示失败提示。这样插件即使在自己的模块里抢先定义别人的元素名,也拿不到别人挂载点的 `host`。归因依赖浏览器调用栈里带脚本地址(三大引擎都满足);不经插件脚本定义的元素(定义者未知)不拦截。 + ## 1. 背景与目标 AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。 diff --git a/webui/src/services/__tests__/plugin-loader.test.ts b/webui/src/services/__tests__/plugin-loader.test.ts index 4a8a1dda..89d942ea 100644 --- a/webui/src/services/__tests__/plugin-loader.test.ts +++ b/webui/src/services/__tests__/plugin-loader.test.ts @@ -70,6 +70,39 @@ describe('loadPluginElement', () => { }); }); +/** 让 define 调用的调用栈带上插件脚本地址,模拟插件模块顶层代码 */ +function defineFrom(pluginId: string, name: string, guarded = false) { + const body = ` + if (!${guarded} || !customElements.get(name)) { + customElements.define(name, class extends HTMLElement {}); + } + //# sourceURL=http://ab.local/api/v1/plugins/${pluginId}/web/index.js`; + // eslint-disable-next-line no-new-func + new Function('name', body)(name); +} + +describe('element ownership', () => { + it('should reject an element that another plugin defined first', async () => { + const slot = makeSlot(); + // 别的插件抢先定义了本插件声明的元素名,本插件自己的 define 被守卫跳过 + const importer = async () => { + defineFrom('squatter', slot.element); + defineFrom(slot.plugin_id, slot.element, true); + }; + + await expect(loadPluginElement(slot, importer)).rejects.toThrow( + /defined by plugin squatter/ + ); + }); + + it('should accept an element that the plugin defined itself', async () => { + const slot = makeSlot(); + await loadPluginElement(slot, async () => + defineFrom(slot.plugin_id, slot.element) + ); + }); +}); + describe('watchPluginErrors', () => { const stops: Array<() => void> = []; afterEach(() => stops.splice(0).forEach((stop) => stop())); diff --git a/webui/src/services/plugin-loader.ts b/webui/src/services/plugin-loader.ts index 3ec868a5..7af21c9d 100644 --- a/webui/src/services/plugin-loader.ts +++ b/webui/src/services/plugin-loader.ts @@ -12,6 +12,24 @@ export function moduleUrl(ui: Pick) { ).href; } +// custom element 名是文档全局的。记录每个名字由哪个插件的脚本定义, +// 挂载点只接受本插件自己定义的元素,其它插件抢先定义(或守卫跳过)的不用。 +const owners = new Map(); +const PLUGIN_FRAME = /\/plugins\/([^/]+)\/web\//; +let tracking = false; + +// 须在任何插件代码运行前安装;按调用栈里最近的插件脚本地址判定定义者 +function trackDefinitions() { + if (tracking) return; + tracking = true; + const define = customElements.define.bind(customElements); + customElements.define = (name, ctor, options) => { + define(name, ctor, options); + const frame = new Error(name).stack?.match(PLUGIN_FRAME); + if (frame) owners.set(name, decodeURIComponent(frame[1])); + }; +} + /** * 导入挂载点所在的模块并确认 custom element 已定义。同一模块只导入一次, * 失败不缓存(插件修复后重新打开页面即可重试)。 @@ -20,6 +38,7 @@ export async function loadPluginElement( ui: PluginUiSlot, importer: Importer = (url) => import(/* @vite-ignore */ url) ): Promise { + trackDefinitions(); const url = moduleUrl(ui); let pending = loading.get(url); if (!pending) { @@ -28,6 +47,12 @@ export async function loadPluginElement( pending.catch(() => loading.delete(url)); } await pending; + const owner = owners.get(ui.element); + if (owner && owner !== ui.plugin_id) { + throw new Error( + `custom element <${ui.element}> is defined by plugin ${owner}` + ); + } if (!customElements.get(ui.element)) { throw new Error(`custom element <${ui.element}> is not defined`); }