Skip to content

feat: 插件管理#2097

Open
ShadowLemoon wants to merge 1 commit into
OneDragon-Anything:mainfrom
ShadowLemoon:feat/plugins-manage
Open

feat: 插件管理#2097
ShadowLemoon wants to merge 1 commit into
OneDragon-Anything:mainfrom
ShadowLemoon:feat/plugins-manage

Conversation

@ShadowLemoon
Copy link
Copy Markdown
Collaborator

@ShadowLemoon ShadowLemoon commented Mar 15, 2026

Summary by CodeRabbit

新功能

  • 插件管理系统
    • 添加完整的插件管理界面,支持导入、预览和删除第三方插件
    • 支持从ZIP文件和本地目录导入插件
    • 导入前可预览插件信息(名称、版本、作者)
    • 自动检测版本冲突并提供覆盖选项
    • 集成插件主页链接和快捷删除功能
    • 新增插件设置页面

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 15, 2026

总体概览

此PR引入了完整的插件导入管理系统,包括后端服务层和前端UI界面。新增PluginImportService类处理ZIP及目录插件的导入、预览、删除操作,同时添加了SettingPluginInterface UI模块供用户管理第三方插件,并将其集成至应用设置界面。

变更清单

分组/文件 摘要
插件导入服务后端
src/one_dragon/base/operation/application/plugin_import_service.py
新增插件导入系统,包含ImportResult和PluginPreviewInfo数据类。PluginImportService类提供ZIP/目录导入、预览、删除等功能,支持覆盖处理,内置ZIP结构验证、常量解析、目录提取等辅助方法。
插件管理UI界面
src/zzz_od/gui/view/setting/setting_plugin_interface.py
新增完整插件管理UI模块。PluginCard类为单个插件卡片,支持显示主页、删除按钮。SettingPluginInterface类提供导入ZIP/目录、刷新、删除、版本比较等工作流,包含冲突警告和降级提示。
应用设置集成
src/zzz_od/gui/view/setting/app_setting_interface.py
导入并注册SettingPluginInterface为应用设置的新子界面,集成到现有设置面板。

序列图

sequenceDiagram
    actor User as 用户
    participant UI as SettingPluginInterface
    participant Dialog as 文件对话框
    participant Service as PluginImportService
    participant FS as 文件系统
    participant Context as OneDragonContext

    User->>UI: 点击导入ZIP
    activate UI
    UI->>Dialog: 打开文件选择
    Dialog-->>UI: 返回ZIP路径
    UI->>Service: preview_plugin(zip_path)
    activate Service
    Service->>FS: 读取ZIP内容
    Service->>Service: 解析*_const.py
    Service-->>UI: PluginPreviewInfo
    deactivate Service
    
    UI->>User: 显示预览&确认对话框
    User->>UI: 确认导入
    
    UI->>Service: import_plugin(zip_path)
    activate Service
    Service->>FS: 验证ZIP结构
    Service->>FS: 检查覆盖冲突
    Service->>FS: 解压插件到目标目录
    Service-->>UI: ImportResult
    deactivate Service
    
    alt 导入成功
        UI->>Context: 更新third_party_plugins
        UI->>UI: 刷新插件卡片列表
        UI->>User: 显示成功提示
    else 版本冲突
        UI->>User: 显示覆盖/降级警告
    end
    deactivate UI
Loading

代码审查工作量评估

🎯 4 (复杂) | ⏱️ ~50 分钟

兔子的庆祝诗

🐰 插件导入系统闪闪发光,
ZIP文件解压快又强,
预览删除样样精通,
UI界面靓靓堂堂,
第三方插件任我装! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title "feat: 插件管理" directly describes the main change - adding a complete plugin management system with import, preview, and delete functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 143-168: plugin_dir_name and extraction currently trust ZIP
entries and can escape plugins_dir; update _get_plugin_dir_name to normalize and
sanitize the candidate name (strip leading slashes, reject or collapse any '..'
segments) and ensure it returns a safe single directory basename, then before
using it compute target_dir = (self.plugins_dir / plugin_dir_name).resolve() and
verify target_dir.is_relative_to(self.plugins_dir.resolve()) (or compare parent
prefixes) to prevent path traversal; similarly, in _extract_plugin validate each
member path by joining to target_dir, resolving, and ensuring the resolved path
remains under plugins_dir before writing, and refuse/skip any absolute or '..'
entries to avoid deleting or writing outside the intended directory (also guard
the overwrite rmtree by verifying target_dir is inside plugins_dir).
- Around line 154-169: The code currently deletes the existing plugin directory
(target_dir) before extracting new contents, which can cause data loss if
extraction fails; change the logic in the import flow (the block around
target_dir, plugin_dir_name, overwrite and calls to self._extract_plugin) to
extract into a temporary directory first (e.g., tmp_target_dir), perform all
validation/checks there, and only after successful extraction/verification
atomically replace the old plugin directory (move/rename tmp_target_dir over
target_dir or remove old and rename tmp_target_dir) to avoid losing the
currently installed version; apply the same pattern to the other similar block
referenced (the second rmtree usage around lines 379-396) so both code paths use
temp extraction + atomic swap instead of rmtree before extraction.
- Around line 205-214: The import validation currently only checks for
*_factory.py (has_factory) and returns ImportResult; add a parallel hard-check
for the plugin const module (e.g., compute has_const =
any(name.endswith('_const.py') for name in file_list)) and if missing return
ImportResult(success=False, plugin_name="", message="无效的插件结构:缺少 *_const.py 文件");
apply the same change to the other validation/preview branches that mirror this
logic (the other ImportResult return sites in this module) so ZIP/dir import and
preview all enforce *_const.py presence, since
application_factory_manager._read_plugin_metadata requires the const module and
will raise ImportError otherwise.
- Around line 457-481: The current parent-directory check on plugin_dir is
bypassable and allows deleting the plugins root; fix by normalizing paths before
checks: call plugin_dir_resolved = Path(plugin_dir).resolve(strict=False) and
plugins_dir_resolved = self.plugins_dir.resolve(strict=False), use
plugin_dir_resolved.name for plugin_name, then reject if not
plugin_dir_resolved.is_relative_to(plugins_dir_resolved) or if
plugin_dir_resolved == plugins_dir_resolved (i.e., disallow deleting the root);
only after these checks call shutil.rmtree(plugin_dir_resolved) to remove the
directory.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 323-336: The installed_plugins map uses p.app_id as key but later
code (ImportResult.plugin_name / result.plugin_name from
plugin_import_service.preview_plugin) supplies plugin directory names, so
lookups miss matches causing downgrade/overwrite checks to be skipped; change
the map key to the plugin directory/name used by the import service (use
whatever property on p represents the directory/name — e.g., p.plugin_name or
p.dir_name) so installed_plugins = { <directory-key>: p for p in
self.ctx.factory_manager.third_party_plugins }, then keep the existing logic
that uses preview = self.plugin_import_service.preview_plugin(fp), new_ver =
preview.version, old_ver = installed_plugins.get(r.plugin_name, None),
is_downgrade = self._is_version_lower(...) so comparisons use the same
identifier for installed_plugins, overwrite_info, and the
ImportResult.plugin_name/result.plugin_name lookups.
- Around line 53-56: The PluginCard currently creates homepage_btn only if
plugin_info.homepage exists, causing recycled cards to miss the button; always
create self.homepage_btn (connected to _on_homepage_clicked) in the PluginCard
initialization and remove the conditional creation, then in the card
update/refresh method (the routine that reapplies plugin_info when cards are
reused) set self.homepage_btn.setVisible(bool(plugin_info.homepage)) and update
any related state (e.g., tooltip or URL) so the button appears or hides
correctly when a card is reused for a plugin that does or does not have a
homepage.
- Around line 38-44: The constructors PluginCard.__init__ and
SettingPluginInterface.__init__ are missing precise type annotations: replace
the built-in callable with collections.abc.Callable, annotate parent as
Optional[Any] (or Optional[QWidget] if using PyQt/PySide), and add explicit
return type -> None; import Optional and Any from typing and Callable from
collections.abc and update the callback parameters to use Callable[..., Any] (or
more specific signatures like Callable[[], None] if known); apply the same
changes to the other __init__ overload at lines ~121-133 so all constructors
have complete modern Python 3.11+ annotations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 257533ae-0984-42b9-8f32-50fde7bc6cd3

📥 Commits

Reviewing files that changed from the base of the PR and between 21882d1 and c46426c.

📒 Files selected for processing (3)
  • src/one_dragon/base/operation/application/plugin_import_service.py
  • src/zzz_od/gui/view/setting/app_setting_interface.py
  • src/zzz_od/gui/view/setting/setting_plugin_interface.py

Comment on lines +143 to +168
plugin_dir_name = self._get_plugin_dir_name(zf)
if not plugin_dir_name:
return ImportResult(
success=False,
plugin_name=zip_path.stem,
message="无法确定插件目录名"
)

target_dir = self.plugins_dir / plugin_dir_name

# 检查目录是否已存在
if target_dir.exists():
if overwrite:
# 覆盖安装:先删除旧目录
shutil.rmtree(target_dir)
log.info(f"覆盖安装: 已删除旧插件目录 {plugin_dir_name}")
else:
return ImportResult(
success=False,
plugin_name=plugin_dir_name,
message=f"插件目录已存在: {plugin_dir_name}",
plugin_dir=target_dir
)

# 解压文件
self._extract_plugin(zf, target_dir, plugin_dir_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

ZIP 条目路径未净化,当前实现可以写出 plugins/ 之外。

这里把归档里的首段路径直接当成 plugin_dir_name,后面又直接拿它参与 target_dir 计算、覆盖删除和解压写盘。只要 ZIP 内包含绝对路径或 ../ 片段,就能把文件写到插件目录外;覆盖分支下甚至可能删错目录。

Also applies to: 283-336

🧰 Tools
🪛 Ruff (0.15.5)

[warning] 156-156: Comment contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 143 - 168, plugin_dir_name and extraction currently trust ZIP entries and
can escape plugins_dir; update _get_plugin_dir_name to normalize and sanitize
the candidate name (strip leading slashes, reject or collapse any '..' segments)
and ensure it returns a safe single directory basename, then before using it
compute target_dir = (self.plugins_dir / plugin_dir_name).resolve() and verify
target_dir.is_relative_to(self.plugins_dir.resolve()) (or compare parent
prefixes) to prevent path traversal; similarly, in _extract_plugin validate each
member path by joining to target_dir, resolving, and ensuring the resolved path
remains under plugins_dir before writing, and refuse/skip any absolute or '..'
entries to avoid deleting or writing outside the intended directory (also guard
the overwrite rmtree by verifying target_dir is inside plugins_dir).

Comment on lines +154 to +169
if target_dir.exists():
if overwrite:
# 覆盖安装:先删除旧目录
shutil.rmtree(target_dir)
log.info(f"覆盖安装: 已删除旧插件目录 {plugin_dir_name}")
else:
return ImportResult(
success=False,
plugin_name=plugin_dir_name,
message=f"插件目录已存在: {plugin_dir_name}",
plugin_dir=target_dir
)

# 解压文件
self._extract_plugin(zf, target_dir, plugin_dir_name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

覆盖安装先删旧目录会把失败场景变成数据丢失。

Line 157 和 Line 381 都是在新内容完成解压/复制之前先 rmtree() 旧插件。后续只要出现一次 I/O 或校验异常,用户当前能用的版本就已经没了。这里应该先导入到临时目录,成功后再替换目标目录。

Also applies to: 379-396

🧰 Tools
🪛 Ruff (0.15.5)

[warning] 156-156: Comment contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 154 - 169, The code currently deletes the existing plugin directory
(target_dir) before extracting new contents, which can cause data loss if
extraction fails; change the logic in the import flow (the block around
target_dir, plugin_dir_name, overwrite and calls to self._extract_plugin) to
extract into a temporary directory first (e.g., tmp_target_dir), perform all
validation/checks there, and only after successful extraction/verification
atomically replace the old plugin directory (move/rename tmp_target_dir over
target_dir or remove old and rename tmp_target_dir) to avoid losing the
currently installed version; apply the same pattern to the other similar block
referenced (the second rmtree usage around lines 379-396) so both code paths use
temp extraction + atomic swap instead of rmtree before extraction.

Comment on lines +205 to +214
# 检查是否有 factory 文件
has_factory = any(name.endswith('_factory.py') for name in file_list)
if not has_factory:
return ImportResult(
success=False,
plugin_name="",
message="无效的插件结构:缺少 *_factory.py 文件"
)

return ImportResult(success=True, plugin_name="", message="")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

导入校验还没有把 *_const.py 当成硬约束。

现在 ZIP/目录导入和预览都只检查 *_factory.py,缺少 *_const.py 的插件仍会被当成可导入对象。按当前加载器的实现,这类插件在后续注册时会直接加载失败,最后变成“导入成功但插件不可用”。

🧩 最小修正方向
-        has_factory = any(name.endswith('_factory.py') for name in file_list)
-        if not has_factory:
+        has_factory = any(name.endswith('_factory.py') for name in file_list)
+        has_const = any(name.endswith('_const.py') for name in file_list)
+        if not (has_factory and has_const):
             return ImportResult(
                 success=False,
                 plugin_name="",
-                message="无效的插件结构:缺少 *_factory.py 文件"
+                message="无效的插件结构:缺少 *_factory.py 或 *_const.py 文件"
             )

目录导入和两个 preview 分支也需要做同样的硬校验。

Based on learnings: 在 src/one_dragon/base/operation/application/application_factory_manager.py 中,插件的 const 模块(*_const.py)是必需的。如果插件缺少对应的 const 模块,_read_plugin_metadata 方法将抛出 ImportError,导致该插件加载失败,这是预期的设计行为。

Also applies to: 235-252, 366-373, 425-446

🧰 Tools
🪛 Ruff (0.15.5)

[warning] 211-211: String contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 205 - 214, The import validation currently only checks for *_factory.py
(has_factory) and returns ImportResult; add a parallel hard-check for the plugin
const module (e.g., compute has_const = any(name.endswith('_const.py') for name
in file_list)) and if missing return ImportResult(success=False, plugin_name="",
message="无效的插件结构:缺少 *_const.py 文件"); apply the same change to the other
validation/preview branches that mirror this logic (the other ImportResult
return sites in this module) so ZIP/dir import and preview all enforce
*_const.py presence, since application_factory_manager._read_plugin_metadata
requires the const module and will raise ImportError otherwise.

Comment on lines +457 to +481
if isinstance(plugin_dir, str):
# 如果只是目录名,构建完整路径
if not Path(plugin_dir).is_absolute():
plugin_dir = self.plugins_dir / plugin_dir

plugin_dir = Path(plugin_dir)
plugin_name = plugin_dir.name

if not plugin_dir.exists():
return ImportResult(
success=False,
plugin_name=plugin_name,
message=f"插件目录不存在: {plugin_name}"
)

# 安全检查:确保删除的是 plugins 目录下的内容
if self.plugins_dir not in plugin_dir.parents and plugin_dir != self.plugins_dir:
return ImportResult(
success=False,
plugin_name=plugin_name,
message="只能删除 plugins 目录下的插件"
)

try:
shutil.rmtree(plugin_dir)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from pathlib import Path

plugins_dir = Path("/tmp/project/plugins")
candidate = plugins_dir / "../foo"

print("candidate =", candidate)
print("parents =", list(candidate.parents))
print("passes_current_check =", plugins_dir in candidate.parents or candidate == plugins_dir)
print("resolved =", candidate.resolve(strict=False))
PY

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 329


🏁 Script executed:

find . -type f -name "plugin_import_service.py" | head -5

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 156


🏁 Script executed:

sed -n '457,481p' ./src/one_dragon/base/operation/application/plugin_import_service.py | cat -n

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 1069


安全检查对路径遍历无效,允许删除 plugins 根目录。

父目录检查在未 resolve() 的路径上进行。如调用 delete_plugin("../foo"),路径变为 plugins/../foo,但 plugins 仍在其 parents 中,通过安全检查后解析为目录外的路径。另外 plugin_dir == self.plugins_dir 条件会被放行,允许用 shutil.rmtree() 删除整个 plugins 根目录。

应先用 plugin_dir.resolve(strict=False) 将路径标准化,再进行父目录检查,并移除 plugin_dir == self.plugins_dir 条件的放行逻辑。

🧰 Tools
🪛 Ruff (0.15.5)

[warning] 458-458: Comment contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF003)


[warning] 472-472: Comment contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 457 - 481, The current parent-directory check on plugin_dir is bypassable
and allows deleting the plugins root; fix by normalizing paths before checks:
call plugin_dir_resolved = Path(plugin_dir).resolve(strict=False) and
plugins_dir_resolved = self.plugins_dir.resolve(strict=False), use
plugin_dir_resolved.name for plugin_name, then reject if not
plugin_dir_resolved.is_relative_to(plugins_dir_resolved) or if
plugin_dir_resolved == plugins_dir_resolved (i.e., disallow deleting the root);
only after these checks call shutil.rmtree(plugin_dir_resolved) to remove the
directory.

Comment on lines +38 to +44
def __init__(
self,
plugin_info: PluginInfo,
on_delete: callable,
on_open_homepage: callable,
parent=None
):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import ast
from pathlib import Path

path = Path("src/zzz_od/gui/view/setting/setting_plugin_interface.py")
tree = ast.parse(path.read_text(encoding="utf-8"))

for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        missing = []
        if node.returns is None:
            missing.append("return")
        for arg in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]:
            if arg.arg not in {"self", "cls"} and arg.annotation is None:
                missing.append(arg.arg)
        uses_builtin_callable = any(
            isinstance(sub, ast.Name) and sub.id == "callable"
            for sub in ast.walk(node)
        )
        if missing or uses_builtin_callable:
            print(
                f"line={node.lineno} name={node.name} "
                f"missing={missing} uses_builtin_callable={uses_builtin_callable}"
            )
PY

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 245


补齐构造函数和回调的类型标注

PluginCard.__init__()SettingPluginInterface.__init__() 缺少返回类型注解,parent 参数和回调参数的类型注解不完整。第 38-44 行使用了内置 callable 而非 collections.abc.Callable,不符合 Python 3.11+ 的现代语法要求。

♻️ 建议修改
+from collections.abc import Callable
...
     def __init__(
         self,
         plugin_info: PluginInfo,
-        on_delete: callable,
-        on_open_homepage: callable,
-        parent=None
-    ):
+        on_delete: Callable[[PluginInfo], None],
+        on_open_homepage: Callable[[PluginInfo], None],
+        parent: QWidget | None = None,
+    ) -> None:
...
-    def __init__(self, ctx: ZContext, parent=None):
+    def __init__(self, ctx: ZContext, parent: QWidget | None = None) -> None:

亦应用于第 121-133 行。根据编码指南要求,所有函数和方法必须包含完整的类型标注,并使用 Python 3.11+ 现代语法。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 38 -
44, The constructors PluginCard.__init__ and SettingPluginInterface.__init__ are
missing precise type annotations: replace the built-in callable with
collections.abc.Callable, annotate parent as Optional[Any] (or Optional[QWidget]
if using PyQt/PySide), and add explicit return type -> None; import Optional and
Any from typing and Callable from collections.abc and update the callback
parameters to use Callable[..., Any] (or more specific signatures like
Callable[[], None] if known); apply the same changes to the other __init__
overload at lines ~121-133 so all constructors have complete modern Python 3.11+
annotations.

Comment on lines +53 to +56
if plugin_info.homepage:
self.homepage_btn = PushButton(text=gt("主页"))
self.homepage_btn.clicked.connect(self._on_homepage_clicked)
buttons.append(self.homepage_btn)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

不要把“主页”按钮的存在性绑定到卡片首次创建时的插件状态。

Line 238-259 会按索引复用 PluginCard,但 Line 53-56 只在首次有 homepage 时才创建按钮,Line 114-115 也只会切换已有按钮的可见性。这样一来,最初绑定到“无主页”插件的卡片在删除/重排后被复用给“有主页”插件时,主页按钮永远不会出现。

💡 一个直接的修正方向
-        if plugin_info.homepage:
-            self.homepage_btn = PushButton(text=gt("主页"))
-            self.homepage_btn.clicked.connect(self._on_homepage_clicked)
-            buttons.append(self.homepage_btn)
+        self.homepage_btn = PushButton(text=gt("主页"))
+        self.homepage_btn.clicked.connect(self._on_homepage_clicked)
+        self.homepage_btn.setVisible(bool(plugin_info.homepage))
+        buttons.append(self.homepage_btn)
...
-        if hasattr(self, 'homepage_btn'):
-            self.homepage_btn.setVisible(bool(plugin_info.homepage))
+        self.homepage_btn.setVisible(bool(plugin_info.homepage))

Also applies to: 113-115, 238-259

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 53 -
56, The PluginCard currently creates homepage_btn only if plugin_info.homepage
exists, causing recycled cards to miss the button; always create
self.homepage_btn (connected to _on_homepage_clicked) in the PluginCard
initialization and remove the conditional creation, then in the card
update/refresh method (the routine that reapplies plugin_info when cards are
reused) set self.homepage_btn.setVisible(bool(plugin_info.homepage)) and update
any related state (e.g., tooltip or URL) so the button appears or hides
correctly when a card is reused for a plugin that does or does not have a
homepage.

Comment on lines +323 to +336
installed_plugins = {
p.app_id: p for p in self.ctx.factory_manager.third_party_plugins
}

# 检查版本信息
overwrite_info = [] # (file_path, plugin_name, new_ver, old_ver, is_downgrade)
for fp, r in existing_plugins:
preview = self.plugin_import_service.preview_plugin(fp)
new_ver = preview.version if preview else None
old_ver = installed_plugins.get(r.plugin_name, None)
old_ver_str = old_ver.version if old_ver else None

is_downgrade = self._is_version_lower(new_ver, old_ver_str)
overwrite_info.append((fp, r.plugin_name, new_ver, old_ver_str, is_downgrade))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

覆盖/降级判断把目录名当成了 app_id

Line 324 的字典键是 p.app_id,但 Line 332 和 Line 479 查的是 ImportResult.plugin_name / result.plugin_name。这两个值在导入服务里实际表示插件目录名,不是 app_id。只要目录名和 app_id 不一致,旧版本就会查不到,降级提示被绕过,覆盖确认也会落到错误对象上。

🔧 建议按目录名对齐当前 service 契约
-            installed_plugins = {
-                p.app_id: p for p in self.ctx.factory_manager.third_party_plugins
-            }
+            installed_plugins = {
+                p.plugin_dir.name: p
+                for p in self.ctx.factory_manager.third_party_plugins
+                if p.plugin_dir is not None
+            }
...
-                old_ver = installed_plugins.get(r.plugin_name, None)
+                old_ver = installed_plugins.get(r.plugin_name)
                 old_ver_str = old_ver.version if old_ver else None
...
-                (p for p in self.ctx.factory_manager.third_party_plugins if p.app_id == result.plugin_name),
+                (
+                    p
+                    for p in self.ctx.factory_manager.third_party_plugins
+                    if p.plugin_dir is not None and p.plugin_dir.name == result.plugin_name
+                ),

Also applies to: 478-485

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 323 -
336, The installed_plugins map uses p.app_id as key but later code
(ImportResult.plugin_name / result.plugin_name from
plugin_import_service.preview_plugin) supplies plugin directory names, so
lookups miss matches causing downgrade/overwrite checks to be skipped; change
the map key to the plugin directory/name used by the import service (use
whatever property on p represents the directory/name — e.g., p.plugin_name or
p.dir_name) so installed_plugins = { <directory-key>: p for p in
self.ctx.factory_manager.third_party_plugins }, then keep the existing logic
that uses preview = self.plugin_import_service.preview_plugin(fp), new_ver =
preview.version, old_ver = installed_plugins.get(r.plugin_name, None),
is_downgrade = self._is_version_lower(...) so comparisons use the same
identifier for installed_plugins, overwrite_info, and the
ImportResult.plugin_name/result.plugin_name lookups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant