插件系统开发

IDE 端插件系统的架构:Manifest、生命周期、桥接与开发约束。

PyriteIDE 使用内嵌 Python 运行时承载插件,通过 Dart Bridge 传输层在 IDE 与 SDK 之间交换协议消息,并由 Flutter 原生渲染器(native.*)及组件注册表渲染插件界面。

运行模型

  • 插件运行在 IDE 进程内嵌的单例 Python 解释器中(PythonRuntimeHost)。
  • 每个插件会话拥有独立的中继 channel(PythonBridge),标签 pyrite.plugin.<pluginId>.<sessionId>
  • 每次启动会话都会产生新的 sessionId 与递增的 generation;重启运行时会重置整个解释器并再次递增 generation
  • 插件类型为 uiservicedata。UI 插件提供视图,Service 插件持续提供后台服务,Data 插件完成 on_contribute() 和挂起响应后自动退出。
IDE (Dart)  ⟷  PythonBridge  ⟷  Python 单例解释器(Plugin A / B / C)

插件目录结构

插件以 ZIP 包分发,解压后是一个插件目录,入口为 __main__.py

src/
├── __main__.py
├── plugin.toml          # Manifest v2
├── requirements.txt
└── assets/              # 静态资源(图片、视频等)
build/                   # pyrsdk package 输出(<id>.zip + .zip.hash)

运行时为每个已安装插件创建 data/cache/sessions/<sessionId>/tmp 目录,并通过环境变量(PYRITE_IDE_PLUGIN_DATA_DIR 等)注入,插件经 self.path.data() / self.path.cache() / self.path.temp() 访问。

启动与安装流程

  1. 用户选择 .zip 插件包。
  2. 校验插件 ID 安全(禁止绝对路径、.. 越界),解压到插件目录。
  3. 解析并校验 plugin.toml(Manifest v2,含权限、激活事件、导航容器与视图贡献)。
  4. 激活事件命中后创建插件会话,完成四阶段握手,再用等待回复的方式发送 start 生命周期钩子。
  5. 停止时发送 LifecycleHook.dispose,等待 Python 目标退出,随后清理临时目录。

Data 插件通过 runOnce 会话运行:run_once() 贡献完成后自动退出,不长期占用解释器。

会话与修订校验

  • PluginSession 携带 sessionIdgenerationtransport、各类目录与 runOnce 标记。
  • 宿主能力为 sdk.v1;所有消息使用协议 v1 并经 session/generation 校验。
  • 运行时重启(restartRuntime)会先 stopAll()resetRuntime() 重置解释器并递增 generation;重置前创建的对象引用全部失效(StaleReferenceError)。

激活协调

ActivationManagerNotifier 负责 onStartuponView:<viewId>onCommand:<commandId>onLanguage:<languageId>。同一插件已有激活 Future 时,后续调用直接等待该 Future,因此并发打开视图或执行命令只会创建一个会话。停用同样按插件合并,且会先等待正在进行的激活结束,避免新会话越过停用操作继续存活。

启动失败由 PluginSessionMetrics 记录并进入指数退避,等待时间从 1 秒增加到最多 60 秒。PluginRunManagerNotifier 在创建会话前检查剩余时间,成功启动后清除失败次数。新增自动恢复入口时,应复用这套检查和计数,不能绕过它直接调用传输层。

协议与队列

协议 v1 使用四阶段握手:ide.initializesdk.initializeide.initializedsdk.ready。每个方向维护独立的严格递增序列号,业务消息只能在 ready 后处理。所有信封包含插件 ID、会话 ID 和代数;请求响应关系由 requestIdreplyTo 表达。

IDE 对插件入站消息分为控制队列和视图补丁队列。控制队列容量为 256,不能静默丢弃;补丁队列容量为 32,压力过大时丢弃最早补丁并依靠视图 resync 恢复。解析任务按插件串行衔接,保持线缆顺序,同时避免一个插件的 JSON 解析阻塞其他会话。

SDK 的 Bridge 出站队列默认容量为 50,每轮最多排空 32 条消息。需要回复的宿主请求作为独立 asyncio task 运行,支持 deadline 和 ide.request.cancel。实现新处理器时,要让取消异常正常传播,并保证每个请求至多回复一次。

停止清理

会话停止包含两端清理:

  1. SDK 执行 on_dispose(),释放事件订阅、命令、视图、请求任务和挂起回调。
  2. PythonRuntimeHost 等待 daemon 目标退出,关闭传输,并在确认退出后删除 cache/sessions/<sessionId>/tmp
  3. PluginRunManagerNotifier 按会话清理 PluginEventBusViewModelStoreComponentMethodRegistry
  4. 运行时整体重启会停止全部会话,重置解释器并清除保留事件及运行时引用状态。

内嵌 daemon 目标没有安全的单目标强制终止能力。停止超时时,会话保持 failed 并继续被宿主追踪,待目标实际结束后再完成清理。不能把关闭 Bridge 传输等同于 Python 代码已经停止。

线程与单例约束

  • Python 在 IDE 进程内的运行时线程执行。SDK 回调位于插件的 Bridge 事件循环,不在 Flutter UI isolate。
  • 所有插件共享一个解释器,全局状态会在插件间共享;插件应使用 插件ID. 前缀避免名称冲突。
  • 一个插件若不正确退出会阻止该插件再次启停;此时“重启运行时”可整体恢复。
  • 插件入口由 daemon 目标承载。Data 插件应使用 run_once(),在挂起响应归零后结束事件循环。

BridgeOutputRouter 为 stdout 和 stderr 安装进程级路由器,再按当前插件线程选择目标 Bridge。插件及其派生线程的输出会进入对应的 IDE 输出流。修改输出捕获时,必须保留原始流作为无会话上下文时的回退,避免插件输出互相串流或递归写回 Bridge。

主要实现位置

  • lib/core/sdk/python_runtime_host.dart:运行时启动锁、会话目录、generation、停止和整体重启。
  • lib/core/sdk/activation_manager.dart:激活状态、触发条件和并发合并。
  • lib/core/sdk/plugin_run_manager.dart:握手、信封校验、入站队列、请求回复和生命周期调用。
  • lib/core/sdk/plugin_run_manager_provider.dart:会话与 Riverpod 状态绑定、退避检查和停止清理。
  • lib/core/sdk/plugin_event_bus.dart:主题注册、权限、投递策略和会话隔离。
  • SDK src/pyrite_sdk/core/bridge.py:插件事件循环、回调表、请求任务、输出路由和出站队列。

开发指南

示例插件

SDK 仓库的 examples/ 提供可直接运行的示例:

  • debug_enhanced_plugin — Ui 插件,贡献一个 native.outline(大纲)与一个 native.variableInspector(设备变量)视图,展示大纲跳转与运行时变量分页查看。
  • service_plugin — Service 插件,监听文件并做后台处理。
  • theme_plugin — Data 插件,贡献 Nord 主题。
  • i18n_plugin — Data 插件,贡献英文语言包。
  • stubs_plugin — Data 插件,贡献 MicroPython 类型存根。

例如,一个贡献大纲与变量视图的 Ui 插件核心结构:

from pyrite_sdk.core.plugin import UiPlugin


class DebugPlugin(UiPlugin):
    def __init__(self):
        super().__init__()
        self.outline = self.views.outline("debug-enhanced.outline", title="Outline")
        self.variables = self.views.variable_inspector(
            "debug-enhanced.device-variables",
            title="Device Variables",
        )

    def on_start(self):
        # 从语言服务获取大纲符号并推送
        self.outline.open()
        self.variables.open()
        self.refresh_symbols()

    def refresh_symbols(self):
        self.documents.get_active(
            lambda **cb: self._fill_outline(cb.get("document"))
        )

    def _fill_outline(self, document):
        if document is None:
            return
        self.documents.symbols(
            document.document_id,
            callback=lambda **cb: print("symbols:", cb),
        )

设备变量视图需要 runtime.inspect 权限,并从运行时作用域分页读取变量(不要通过 serial.run_python 打断设备)。

On this page