mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 14:45:26 +08:00
fix(4.0-P6): 嵌套秘密字段掩码、未启用插件 schema 刷新、custom element 归属校验
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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-<id>`` 命名空间内。"""
|
||||
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-<id> 命名空间内,
|
||||
# 一个插件才不会占用别的插件的元素名
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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-<id>` 或以 `ab-plugin-<id>-` 开头。id 含连字符时命名空间会重叠(`foo` 的前缀也匹配 `ab-plugin-foo-bar`),`PluginManager.ui_slots()` 按全部已发现插件取 id 最长者为归属,其余声明被忽略并在重新发现时写警告日志。
|
||||
- **前端按定义者校验元素**(`services/plugin-loader.ts`):加载器包装 `customElements.define`,按调用栈里最近的 `/plugins/<id>/web/` 脚本地址记录每个名字由哪个插件定义;挂载点发现元素由其它插件定义时拒绝实例化并显示失败提示。这样插件即使在自己的模块里抢先定义别人的元素名,也拿不到别人挂载点的 `host`。归因依赖浏览器调用栈里带脚本地址(三大引擎都满足);不经插件脚本定义的元素(定义者未知)不拦截。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -12,6 +12,24 @@ export function moduleUrl(ui: Pick<PluginUiSlot, 'plugin_id' | 'entry'>) {
|
||||
).href;
|
||||
}
|
||||
|
||||
// custom element 名是文档全局的。记录每个名字由哪个插件的脚本定义,
|
||||
// 挂载点只接受本插件自己定义的元素,其它插件抢先定义(或守卫跳过)的不用。
|
||||
const owners = new Map<string, string>();
|
||||
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<void> {
|
||||
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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user