Skip to content

feat: 插件管理 - #2097

Open
ShadowLemoon wants to merge 6 commits into
OneDragon-Anything:mainfrom
ShadowLemoon:feat/plugins-manage
Open

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

Conversation

@ShadowLemoon

@ShadowLemoon ShadowLemoon commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • 新功能
    • 完整插件管理:在设置中新增插件管理页面,支持导入、预览、刷新、打开目录与删除第三方插件
    • 多种导入方式:支持从 ZIP 文件或本地目录导入,并在导入前预览插件名称、版本与作者信息
    • 覆盖与冲突处理:自动检测版本冲突,提供覆盖确认与降级提示
    • 插件操作便捷:插件卡片展示信息、跳转主页与一键删除功能

Review Change Stack

@coderabbitai

coderabbitai Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

新增

  • 新增插件管理页面,支持导入、预览、刷新、打开目录和删除插件。
  • 新增 ZIP 文件、单个目录和插件集合目录导入功能。
  • 新增插件信息预览、版本比较、覆盖安装和降级提示。
  • 新增插件卡片,支持显示版本信息、打开主页和删除插件。
  • 新增插件导入与管理规则文档。

变更

  • 在设置页面注册插件管理子页面。
  • 将第三方插件扫描范围限制为插件根目录第一层的唯一 *_factory.py
  • 导入成功后刷新应用注册信息和插件列表。
  • 为多插件导入提供按名称筛选和独立失败回滚。

安全

  • 校验插件路径,拒绝绝对路径、上级路径、非法目录名、重复路径、文件与目录冲突、符号链接及越界写入。
  • 限制插件解压总大小为 512 MiB。
  • 导入插件前使用临时目录校验,并在替换失败时执行备份回滚。
  • 删除操作仅允许删除 plugins 下的顶层真实插件目录。

Walkthrough

新增 PluginImportService,支持 ZIP 和目录插件的结构校验、预览、临时安装、覆盖回滚及安全删除。新增 SettingPluginInterface,提供插件列表、导入确认、降级提示、刷新、删除和主页导航。第三方插件扫描改为仅处理插件根目录第一层文件。

Changes

插件导入与管理

Layer / File(s) Summary
导入契约与来源分析
src/one_dragon/base/operation/application/plugin_import_service.py
新增导入结果和预览数据类。新增 ZIP/目录来源的单插件、批量、筛选和预览接口。校验插件根目录及唯一的 *_factory.py*_const.py
ZIP 校验与覆盖安装
src/one_dragon/base/operation/application/plugin_import_service.py
校验 ZIP 路径、重复成员、文件目录冲突、体积和越界写入。内容先写入临时目录,再通过备份和回滚替换目标目录。
目录导入与安全删除
src/one_dragon/base/operation/application/plugin_import_service.py
校验目录结构和符号链接。目录插件先复制并重新校验,再执行可回滚安装。删除操作仅允许删除 plugins 下的顶层真实目录。
设置界面与插件操作流程
src/zzz_od/gui/view/setting/setting_plugin_interface.py, src/zzz_od/gui/view/setting/app_setting_interface.py
新增插件卡片和设置子页面,提供 ZIP/目录导入、预览、覆盖与降级确认、刷新、删除和主页导航。导入或删除后刷新插件注册和列表。
插件扫描规则与文档
src/one_dragon/base/operation/application/application_factory_manager.py, docs/develop/guides/application_plugin_guide.md, docs/develop/one_dragon/modules/application_plugin_system.md, plugins/README.md
第三方插件仅扫描插件根目录第一层的真实 Python 文件。文档同步说明插件根、集合来源、路径安全、符号链接、覆盖回滚和删除规则。

Estimated code review effort: 4 (复杂) | ~60 分钟

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant SettingPluginInterface
    participant PluginImportService
    participant FileSystem
    participant ApplicationFactoryManager

    User->>SettingPluginInterface: 选择 ZIP 或插件目录
    SettingPluginInterface->>PluginImportService: 请求预览
    PluginImportService->>FileSystem: 分析结构并读取插件元数据
    FileSystem-->>PluginImportService: 返回预览信息
    PluginImportService-->>SettingPluginInterface: 返回 PluginPreviewInfo
    User->>SettingPluginInterface: 确认导入或覆盖
    SettingPluginInterface->>PluginImportService: 导入选定插件
    PluginImportService->>FileSystem: 校验、临时写入并替换目标目录
    FileSystem-->>PluginImportService: 返回安装结果
    PluginImportService-->>SettingPluginInterface: 返回 ImportResult
    SettingPluginInterface->>ApplicationFactoryManager: 刷新应用注册
    ApplicationFactoryManager-->>SettingPluginInterface: 返回更新后的插件列表
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

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.
Description check ❓ Inconclusive 作者未提供拉取请求描述,因此无法确认其是否说明了插件管理相关变更。 补充拉取请求描述,说明插件导入、预览、覆盖、删除及插件管理界面的主要变更。
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 标题“feat: 插件管理”准确概括了新增插件导入、管理和设置界面的主要变更。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

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.

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 thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
Comment thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
Comment thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated
@ShadowLemoon
ShadowLemoon force-pushed the feat/plugins-manage branch from c46426c to 284ccc7 Compare May 20, 2026 11:08

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 316-336: The has_root_dir test is too strict and fails when ZIP
contains extra metadata entries (e.g., __MACOSX, .DS_Store), causing files to be
written under an extra nested plugin folder; update the logic in
plugin_import_service.py around has_root_dir / file_list handling to first
filter file_list to only relevant plugin entries (or compute the common root
prefix among non-metadata entries) and then use that prefix when extracting: if
the archive truly contains a single plugin root, call
zf.extractall(self.plugins_dir) or, when writing members in the loop, strip the
detected root prefix from each member before creating directories/writing files
(adjust the existing branch that uses target_dir, zf.read(member), and dest_path
so dest_path is built from member with the root prefix removed).
- Around line 205-214: 当前 ZIP 校验通过只要任意深度存在 *_factory.py,导致二级嵌套(如
pkg/src/foo_factory.py)被误判为可导入插件;在 plugin_import_service.py
中把有用的检测从任意深度改为仅检查归档根目录或根目录的第一层(即只遍历 zip 根下的文件和直接子目录),更新生成 has_factory
的逻辑以只在这些路径下搜索 *_factory.py,并确保返回的 plugin_name
根据找到的根目录(顶层或第一层目录名)来设置;同样将相同修正应用到文件中另一处相同检查(原注记所指 283-291
区域)以保持行为一致并避免后续导入/预览路径不匹配(参考变量名 has_factory 和返回类型 ImportResult 以定位代码片段)。
- Around line 70-71: The __init__ method in plugin_import_service.py is missing
return and member type annotations; update the signature of __init__ to include
a return type "-> None" and add an explicit annotation for the instance
attribute (self.ctx: OneDragonContext) so that both the constructor signature
and the class member comply with the repo's src/**/*.py typing rules (refer to
the __init__ method and the self.ctx attribute in the PluginImportService
class).
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 068bdb22-338a-4d1f-81aa-0f63c6f9c499

📥 Commits

Reviewing files that changed from the base of the PR and between c46426c and 284ccc7.

📒 Files selected for processing (1)
  • src/one_dragon/base/operation/application/plugin_import_service.py

Comment thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
Comment thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
Comment thread src/one_dragon/base/operation/application/plugin_import_service.py Outdated
@ShadowLemoon ShadowLemoon added the test-branch 合入 test 分支 label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🧹 Nitpick comments (7)
src/one_dragon/base/operation/application/plugin_import_service.py (4)

1-14: 📐 Maintainability & Code Quality | 🔵 Trivial

请补充对应的测试与开发文档。

本文件包含 ZIP 路径净化、覆盖回滚、删除路径约束等安全关键逻辑。当前 PR 中未见 zzz-od-test/ 下的对应测试。请为 _normalize_zip_member_path_analyze_zip_replace_plugin_dirdelete_plugin 补充用例,重点覆盖路径穿越、覆盖失败回滚和删除根目录这三类场景。

需要我生成测试骨架吗?

As per coding guidelines, **/*.py: 修改代码后同步更新对应的 docs/develop/ 文档与 zzz-od-test/ 测试。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 1 - 14, 为 application/plugin_import_service.py 中的
_normalize_zip_member_path、_analyze_zip、_replace_plugin_dir 和 delete_plugin 补充
zzz-od-test/ 测试,覆盖 ZIP 路径穿越、覆盖安装失败后的回滚,以及拒绝删除插件根目录等场景;同时按项目规范更新 docs/develop/
中对应的开发文档,说明这些安全行为。

Source: Coding guidelines


88-101: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

plugins_dir 每次访问都重新计算。

该属性执行 inspect.getfile 与路径拼接,_plugins_root() 还会再做一次 resolve()import_plugins 批量导入时会重复调用多次。项目根目录在运行期不变,可以用 functools.cached_property 缓存。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 88 - 101, 将 ApplicationPluginImportService 的 plugins_dir 属性改为使用
functools.cached_property,使 inspect.getfile 和路径拼接仅在首次访问时执行;保留现有项目根目录判断及 plugins
路径结果不变,并确保导入对应的 cached_property。

339-346: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

只剥离 root_prefix 顶层目录,其余顶层条目会被原样写入。

_analyze_zip 只保证 factory 文件的顶层目录唯一(第 289-291 行),不保证所有成员的顶层目录唯一。如果 ZIP 同时包含 my_plugin/assets/assets/ 不匹配 root_prefix,会被写成 plugins/my_plugin/assets/,与插件根目录约定不符。

建议在 _analyze_zip 中,当 root_prefix 不为 None 时拒绝其它顶层条目,或在 _extract_plugin 中跳过它们。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 339 - 346, 在 `_analyze_zip` 中处理 `root_prefix` 非空的 ZIP 时,校验每个成员的顶层目录都必须是
`root_prefix`;发现其他顶层条目(如 `assets/`)时拒绝该 ZIP,避免 `_extract_plugin` 将其写入插件根目录。保留现有对
`root_prefix` 目录剥离及空相对路径跳过的逻辑。

197-203: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

移除无调用的 _validate_zip_structure

该方法只在 src/one_dragon/base/operation/application/plugin_import_service.py 中声明,且无调用点;import_pluginpreview_plugin 都直接使用 _analyze_zip。移除能减少冗余代码。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 197 - 203, Remove the unused _validate_zip_structure method from
PluginImportService, including its ValueError handling and ImportResult
construction; retain the existing _analyze_zip usage in import_plugin and
preview_plugin unchanged.
src/zzz_od/gui/view/setting/setting_plugin_interface.py (3)

207-215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_clear_plugin_cards 未被调用,与 _refresh_plugin_list 内的隐藏逻辑重复。

Line 230-232 内联了相同的隐藏逻辑。建议删除 _clear_plugin_cards,或在 _refresh_plugin_list 中复用它以消除重复。

♻️ 建议修改(复用方案)
         if not third_party_plugins:
             # 隐藏所有插件卡片,显示空状态
-            for card in self._plugin_cards:
-                with contextlib.suppress(RuntimeError):
-                    card.hide()
+            self._clear_plugin_cards()
             self._empty_card.show()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 207 -
215, Remove the duplicated card-hiding loop from _refresh_plugin_list and invoke
_clear_plugin_cards there instead, preserving the existing RuntimeError
suppression and behavior of hiding all cards without deleting them.

305-309: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

gt(f'...') 会把插值后的字符串当作翻译键。

f-string 先完成插值,gt 收到的是含动态内容的完整句子,无法命中任何译文条目。建议先翻译模板,再插值。同样适用于 Line 350-354、366-370、452-456、469-477、490、497、509-517、583、602。

♻️ 建议修改
-            content=gt(f'即将导入以下插件:\n\n{chr(10).join(preview_lines)}'),
+            content=f'{gt("即将导入以下插件:")}\n\n{chr(10).join(preview_lines)}',
...
-                content=gt(f'成功导入 {success_count} 个插件'),
+                content=gt('成功导入 {count} 个插件').format(count=success_count),

Also applies to: 396-404

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 305 -
309, 更新 setting_plugin_interface.py 中生成导入提示的相关逻辑,避免将插值后的完整字符串传给
gt;先翻译包含占位符的固定模板,再插入 preview_lines 等动态内容。对同文件中对应的提示文本(包括 MessageBox、行
350-354、366-370、396-404、452-456、469-477、490、497、509-517、583 和
602)统一采用相同方式,并保持现有显示内容不变。

666-674: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

避免将 packaging 降级比较误判为可用后备方案。

packaging 在 repo 中未声明为通用依赖,Windows lock entry 下的本地环境不一定可用;except Exception 涵盖 ImportError 后会把 ImportError 判定为无效版本号并直接返回字符串比较,导致 new_ver = '10.0'old_ver = '9.0' 时错误判断为降级。把导入提到顶层、显式处理 ImportError,并对非法版本号只返回 False

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 666 -
674, 更新版本降级判断逻辑及其模块导入:将 packaging.version 提升为顶层依赖并显式处理 ImportError,缺少 packaging
时不要回退到字符串比较;仅在版本解析抛出版本格式异常时返回 False,确保合法版本使用 version.parse 比较,非法版本不判定为降级。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 331-358: 在 _extract_plugin 中增加解压总容量限制,使用模块级常量
_MAX_EXTRACT_SIZE(512 MiB)作为阈值;遍历成员时仅对非目录项累加
info.file_size,并在实际写入前检查累计值,超过阈值立即抛出 ValueError,避免继续创建或写入文件。

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 179-184: Update the HelpCard initialization in the setting
interface so its url points to the online version of
docs/develop/guides/application_plugin_guide.md instead of an empty string,
while preserving the existing title, content, and action_group.addSettingCard
call.
- Around line 592-595: 在删除流程中更新 `_get_plugin_package_dir` 返回 `None`
的分支,调用现有界面错误提示机制告知用户删除失败;同时将 `delete_plugin` 调用及其后续处理移出条件分支,保持有效插件目录的删除行为不变。
- Around line 565-576: Update _on_open_dir_clicked to open plugins_dir through
the already imported QDesktopServices cross-platform API instead of the
sys.platform branches and os/subprocess calls. Preserve directory creation and
remove the now-unused os, subprocess, and sys imports.
- Around line 166-167: Update the open_market_btn click handler to pass a QUrl
instance to QDesktopServices.openUrl instead of a raw string, preserving the
existing plugin registry URL and button behavior.

---

Nitpick comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 1-14: 为 application/plugin_import_service.py 中的
_normalize_zip_member_path、_analyze_zip、_replace_plugin_dir 和 delete_plugin 补充
zzz-od-test/ 测试,覆盖 ZIP 路径穿越、覆盖安装失败后的回滚,以及拒绝删除插件根目录等场景;同时按项目规范更新 docs/develop/
中对应的开发文档,说明这些安全行为。
- Around line 88-101: 将 ApplicationPluginImportService 的 plugins_dir 属性改为使用
functools.cached_property,使 inspect.getfile 和路径拼接仅在首次访问时执行;保留现有项目根目录判断及 plugins
路径结果不变,并确保导入对应的 cached_property。
- Around line 339-346: 在 `_analyze_zip` 中处理 `root_prefix` 非空的 ZIP
时,校验每个成员的顶层目录都必须是 `root_prefix`;发现其他顶层条目(如 `assets/`)时拒绝该 ZIP,避免
`_extract_plugin` 将其写入插件根目录。保留现有对 `root_prefix` 目录剥离及空相对路径跳过的逻辑。
- Around line 197-203: Remove the unused _validate_zip_structure method from
PluginImportService, including its ValueError handling and ImportResult
construction; retain the existing _analyze_zip usage in import_plugin and
preview_plugin unchanged.

In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 207-215: Remove the duplicated card-hiding loop from
_refresh_plugin_list and invoke _clear_plugin_cards there instead, preserving
the existing RuntimeError suppression and behavior of hiding all cards without
deleting them.
- Around line 305-309: 更新 setting_plugin_interface.py
中生成导入提示的相关逻辑,避免将插值后的完整字符串传给 gt;先翻译包含占位符的固定模板,再插入 preview_lines
等动态内容。对同文件中对应的提示文本(包括 MessageBox、行
350-354、366-370、396-404、452-456、469-477、490、497、509-517、583 和
602)统一采用相同方式,并保持现有显示内容不变。
- Around line 666-674: 更新版本降级判断逻辑及其模块导入:将 packaging.version 提升为顶层依赖并显式处理
ImportError,缺少 packaging 时不要回退到字符串比较;仅在版本解析抛出版本格式异常时返回 False,确保合法版本使用
version.parse 比较,非法版本不判定为降级。
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 420eeac6-008d-4f0c-b314-e38448663834

📥 Commits

Reviewing files that changed from the base of the PR and between f64e484 and a86d268.

📒 Files selected for processing (4)
  • docs/develop/guides/application_plugin_guide.md
  • 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
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/zzz_od/gui/view/setting/app_setting_interface.py

Comment thread src/one_dragon/base/operation/application/plugin_import_service.py
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated
Comment thread src/zzz_od/gui/view/setting/setting_plugin_interface.py Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/one_dragon/base/operation/application/application_factory_manager.py`:
- Around line 145-156: 在 discover_factories() 使用的目录扫描流程中,更新 _scan_directory()
及其根目录解析、plugin_dir 枚举和 python_file 枚举逻辑,捕获预期的 OSError 与
RuntimeError。记录对应目录扫描失败后继续处理其他目录或插件,避免 resolve(strict=True)、iterdir() 或 glob()
的异常中断全部插件发现。
- Around line 142-147: 更新 _scan_directory() 中 source == PluginSource.THIRD_PARTY
的扫描逻辑:对 plugins 目录下每个真实插件根目录仅检查第一层的 *_factory.py 文件,避免使用会递归发现嵌套 factory 的
directory.rglob。保持内置应用路径的递归扫描行为不变,并继续处理目录不存在或不可用的情况。
- Around line 147-155: 在插件扫描循环中更新 application factory manager 的目录遍历逻辑:不要直接使用无序的
directory.iterdir(),先按稳定键对插件目录排序,再依序执行现有的符号链接、目录有效性、解析路径和 APP_ID 注册流程。保持重复
APP_ID 时首次注册插件保留的行为不变。

In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 539-559: Expand the exception handling in
import_plugins_from_directory and import_directory to catch filesystem and other
directory-analysis failures, including OSError/PermissionError, instead of only
ValueError. Return the existing failed ImportResult response with the exception
message, matching the broader handling used by preview_plugins_from_directory.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b3cb7198-2eae-41be-90f5-9cb547f038cf

📥 Commits

Reviewing files that changed from the base of the PR and between a86d268 and 0dd49dd.

📒 Files selected for processing (6)
  • docs/develop/guides/application_plugin_guide.md
  • docs/develop/one_dragon/modules/application_plugin_system.md
  • plugins/README.md
  • src/one_dragon/base/operation/application/application_factory_manager.py
  • src/one_dragon/base/operation/application/plugin_import_service.py
  • src/zzz_od/gui/view/setting/setting_plugin_interface.py

Comment on lines +142 to +147
# 内置应用递归扫描;第三方插件只扫描每个真实插件根的第一层。
if source == PluginSource.THIRD_PARTY:
python_files: list[Path] = []
if directory.is_dir():
directory_resolved = directory.resolve(strict=True)
for plugin_dir in directory.iterdir():

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline \
  src/zzz_od/application/devtools/application_priority_scanner.py \
  --items all \
  --type function

rg -n -C 8 \
  '\bApplicationPriorityScanner\b|\b_scan_directory\s*\(|PluginSource\.(BUILTIN|THIRD_PARTY)' \
  src/zzz_od src/one_dragon

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 22112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n src/zzz_od/application/devtools/application_priority_scanner.py | sed -n '1,135p'
printf '\n--- manager scan implementation ---\n'
cat -n src/one_dragon/base/operation/application/application_factory_manager.py | sed -n '123,214p'
printf '\n--- scanner consumers and generated priority fields ---\n'
rg -n -C 6 \
  'scan_application_priorities|ApplicationPriorityItem|app_priority|priority' \
  src/zzz_od/application src/one_dragon/base/operation/application \
  -g '*.py' | head -n 260

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 36449


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

scanner = Path("src/zzz_od/application/devtools/application_priority_scanner.py")
tree = ast.parse(scanner.read_text(encoding="utf-8"), filename=str(scanner))

scan_fn = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "_scan_directory"
)
rglob_calls = [
    ast.unparse(node)
    for node in ast.walk(scan_fn)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "rglob"
]
scan_app_fn = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "scan_application_priorities"
)
third_party_calls = [
    ast.unparse(node)
    for node in ast.walk(scan_app_fn)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Name)
    and node.func.id == "_scan_directory"
    and len(node.args) >= 2
    and isinstance(node.args[1], ast.Constant)
    and node.args[1].value == "third_party"
]

print({"_scan_directory_rglob_calls": rglob_calls})
print({"third_party_scan_calls": third_party_calls})
assert rglob_calls == ["directory.rglob('*_factory.py')"]
assert third_party_calls
PY

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

scanner = Path("src/zzz_od/application/devtools/application_priority_scanner.py")
tree = ast.parse(scanner.read_text(encoding="utf-8"), filename=str(scanner))

scan_app_fn = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "scan_application_priorities"
)

append_calls = [
    ast.unparse(node)
    for node in ast.walk(scan_app_fn)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "append"
]
scan_calls = [
    ast.unparse(node)
    for node in ast.walk(scan_app_fn)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Name)
    and node.func.id == "_scan_directory"
]

print({"scan_directory_calls": scan_calls})
print({"scan_dir_appends": append_calls})
assert any("'third_party'" in call for call in append_calls)
assert any("source" in call for call in scan_calls)
PY

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 334


同步第三方插件的扫描边界。

scan_application_priorities() 会将 plugins 目录作为 third_party 传入 _scan_directory()。该方法仍执行 directory.rglob('*_factory.py'),会报告运行时不会注册的嵌套 factory。

source == 'third_party' 时,只扫描每个插件根目录的第一层。内置目录继续使用递归扫描。

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 142-142: Comment contains ambiguous (FULLWIDTH SEMICOLON). Did you mean ; (SEMICOLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 142 - 147, 更新 _scan_directory() 中 source ==
PluginSource.THIRD_PARTY 的扫描逻辑:对 plugins 目录下每个真实插件根目录仅检查第一层的 *_factory.py
文件,避免使用会递归发现嵌套 factory 的 directory.rglob。保持内置应用路径的递归扫描行为不变,并继续处理目录不存在或不可用的情况。

Comment on lines +145 to +156
if directory.is_dir():
directory_resolved = directory.resolve(strict=True)
for plugin_dir in directory.iterdir():
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
continue
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except (OSError, RuntimeError):
continue
if plugin_dir_resolved != directory_resolved / plugin_dir.name:
continue
for python_file in plugin_dir.glob("*.py"):

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

隔离插件目录扫描中的文件系统异常。

discover_factories() 只在调用前检查 plugin_dir.is_dir()。目录在检查后被删除、替换,或路径解析遇到 OSErrorRuntimeError 时,resolve(strict=True)iterdir()glob() 会直接抛出。

这些异常会越过 _scan_directory() 的工厂级异常处理,并中断全部插件扫描。请捕获根目录解析、目录枚举和单个插件文件枚举中的预期文件系统异常。记录失败后继续扫描其他插件。

建议的异常隔离方式
         if source == PluginSource.THIRD_PARTY:
             python_files: list[Path] = []
             if directory.is_dir():
-                directory_resolved = directory.resolve(strict=True)
-                for plugin_dir in directory.iterdir():
+                try:
+                    directory_resolved = directory.resolve(strict=True)
+                    plugin_dirs = list(directory.iterdir())
+                except (FileNotFoundError, NotADirectoryError):
+                    return non_default_factories, default_factories
+                except (OSError, RuntimeError) as exc:
+                    self._scan_failures.append((directory, f"{type(exc).__name__}: {exc}"))
+                    return non_default_factories, default_factories
+
+                for plugin_dir in plugin_dirs:
                     if plugin_dir.is_symlink() or not plugin_dir.is_dir():
                         continue
...
-                    for python_file in plugin_dir.glob("*.py"):
+                    try:
+                        python_files_in_plugin = list(plugin_dir.glob("*.py"))
+                    except (OSError, RuntimeError) as exc:
+                        self._scan_failures.append((plugin_dir, f"{type(exc).__name__}: {exc}"))
+                        continue
+                    for python_file in python_files_in_plugin:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 145 - 156, 在 discover_factories() 使用的目录扫描流程中,更新 _scan_directory()
及其根目录解析、plugin_dir 枚举和 python_file 枚举逻辑,捕获预期的 OSError 与
RuntimeError。记录对应目录扫描失败后继续处理其他目录或插件,避免 resolve(strict=True)、iterdir() 或 glob()
的异常中断全部插件发现。

Comment on lines +147 to +155
for plugin_dir in directory.iterdir():
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
continue
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except (OSError, RuntimeError):
continue
if plugin_dir_resolved != directory_resolved / plugin_dir.name:
continue

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

curl -fsSL 'https://docs.python.org/3.11/library/pathlib.html' \
  | grep -F 'The children are yielded in arbitrary order'

rg -n -C 4 'iterdir\(\)|重复的 APP_ID|先注册者胜' \
  src/one_dragon/base/operation/application/application_factory_manager.py \
  docs/develop/one_dragon/modules/application_plugin_system.md

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 4444


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate structure ---'
ast-grep outline src/one_dragon/base/operation/application/application_factory_manager.py

printf '%s\n' '--- scan and registration symbols ---'
rg -n -C 8 \
  'def (scan|_scan|_register_plugin_metadata|_read_plugin_metadata)|_register_plugin_metadata\(|_read_plugin_metadata\(|_plugin_infos|scan_failures' \
  src/one_dragon/base/operation/application/application_factory_manager.py

printf '%s\n' '--- relevant source sections ---'
sed -n '110,190p' src/one_dragon/base/operation/application/application_factory_manager.py
sed -n '360,455p' src/one_dragon/base/operation/application/application_factory_manager.py

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 14038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from dataclasses import dataclass

`@dataclass`(frozen=True)
class Plugin:
    directory: str
    app_id: str

def scan(order: list[Plugin]) -> tuple[str, list[str]]:
    registered: dict[str, Plugin] = {}
    failures: list[str] = []
    for plugin in order:
        if plugin.app_id in registered:
            failures.append(plugin.directory)
            continue
        registered[plugin.app_id] = plugin
    winner = registered["same"].directory
    return winner, failures

plugins = [
    Plugin("Zoo", "same"),
    Plugin("alpha", "same"),
]
sorted_plugins = sorted(plugins, key=lambda plugin: (plugin.directory.casefold(), plugin.directory))

assert scan(plugins) == ("Zoo", ["alpha"])
assert scan(list(reversed(plugins))) == ("alpha", ["Zoo"])
assert scan(sorted_plugins) == ("alpha", ["Zoo"])

print("unsorted order 1:", scan(plugins))
print("unsorted order 2:", scan(list(reversed(plugins))))
print("proposed sorted order:", [plugin.directory for plugin in sorted_plugins])
print("proposed sorted result:", scan(sorted_plugins))
PY

Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon

Length of output: 339


稳定第三方插件的加载顺序。

Path.iterdir() 按任意顺序返回目录项。重复 APP_ID 时,首次注册的插件保留,后续插件加载失败。因此插件选择可能随文件系统顺序变化。按稳定键排序插件目录后再扫描。

建议的排序方式
-                for plugin_dir in directory.iterdir():
+                for plugin_dir in sorted(
+                    directory.iterdir(),
+                    key=lambda path: (path.name.casefold(), path.name),
+                ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for plugin_dir in directory.iterdir():
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
continue
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except (OSError, RuntimeError):
continue
if plugin_dir_resolved != directory_resolved / plugin_dir.name:
continue
for plugin_dir in sorted(
directory.iterdir(),
key=lambda path: (path.name.casefold(), path.name),
):
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
continue
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except (OSError, RuntimeError):
continue
if plugin_dir_resolved != directory_resolved / plugin_dir.name:
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 147 - 155, 在插件扫描循环中更新 application factory manager 的目录遍历逻辑:不要直接使用无序的
directory.iterdir(),先按稳定键对插件目录排序,再依序执行现有的符号链接、目录有效性、解析路径和 APP_ID 注册流程。保持重复
APP_ID 时首次注册插件保留的行为不变。

Comment on lines +539 to +559
try:
layouts = self._analyze_directory(dir_path)
if selected_plugin_names is not None:
requested_names = {name.casefold() for name in selected_plugin_names}
available_names = {layout.plugin_dir_name.casefold() for layout in layouts}
missing_names = sorted(requested_names - available_names)
if missing_names:
return [
ImportResult(
success=False,
plugin_name=source_name,
message=f"目录中不存在指定插件: {', '.join(missing_names)}",
)
]
layouts = [
layout
for layout in layouts
if layout.plugin_dir_name.casefold() in requested_names
]
except ValueError as e:
return [ImportResult(success=False, plugin_name=source_name, message=str(e))]

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

import_plugins_from_directory 的异常捕获过窄,OSError 会传播到 Qt 槽函数。

_analyze_directory 会经由 _collect_directory_file_paths_validate_directory_symlinks 触发文件系统调用。这些调用在权限不足、路径过长或遍历期间目录被删除时抛出 OSError/PermissionError,而不是 ValueError。这里只捕获 ValueError,异常会向上传播。

调用方 src/zzz_od/gui/view/setting/setting_plugin_interface.py_on_import_dir_clicked(Line 494)没有 try 包裹。PySide6 6.5+ 默认在槽函数中出现未处理异常时终止进程。

同一文件中 preview_plugins_from_directory(Line 664)使用 except Exception,两条路径的容错级别不一致。

🛠️ 建议修改
-        except ValueError as e:
+        except (ValueError, OSError) as e:
             return [ImportResult(success=False, plugin_name=source_name, message=str(e))]

import_directory(Line 514)同样只捕获 ValueError,建议一并调整。

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
layouts = self._analyze_directory(dir_path)
if selected_plugin_names is not None:
requested_names = {name.casefold() for name in selected_plugin_names}
available_names = {layout.plugin_dir_name.casefold() for layout in layouts}
missing_names = sorted(requested_names - available_names)
if missing_names:
return [
ImportResult(
success=False,
plugin_name=source_name,
message=f"目录中不存在指定插件: {', '.join(missing_names)}",
)
]
layouts = [
layout
for layout in layouts
if layout.plugin_dir_name.casefold() in requested_names
]
except ValueError as e:
return [ImportResult(success=False, plugin_name=source_name, message=str(e))]
try:
layouts = self._analyze_directory(dir_path)
if selected_plugin_names is not None:
requested_names = {name.casefold() for name in selected_plugin_names}
available_names = {layout.plugin_dir_name.casefold() for layout in layouts}
missing_names = sorted(requested_names - available_names)
if missing_names:
return [
ImportResult(
success=False,
plugin_name=source_name,
message=f"目录中不存在指定插件: {', '.join(missing_names)}",
)
]
layouts = [
layout
for layout in layouts
if layout.plugin_dir_name.casefold() in requested_names
]
except (ValueError, OSError) as e:
return [ImportResult(success=False, plugin_name=source_name, message=str(e))]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 539 - 559, Expand the exception handling in import_plugins_from_directory
and import_directory to catch filesystem and other directory-analysis failures,
including OSError/PermissionError, instead of only ValueError. Return the
existing failed ImportResult response with the exception message, matching the
broader handling used by preview_plugins_from_directory.

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

Labels

test-branch 合入 test 分支

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant