Skip to main content

zerolaunch_plugin_host/
manager.rs

1//! PluginHostManager — 第三方插件的顶层编排管理器。
2
3use dashmap::DashMap;
4use parking_lot::RwLock;
5use std::collections::HashSet;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::sync::atomic::{AtomicU32, Ordering};
10use std::sync::{Arc, OnceLock};
11use tokio::sync::mpsc;
12use tokio::sync::Mutex as AsyncMutex;
13use tracing::{error, info, warn};
14
15use zerolaunch_plugin_api::config::Configurable;
16use zerolaunch_plugin_api::plugin::PluginMetadata;
17use zerolaunch_plugin_protocol::manifest::Manifest;
18use zerolaunch_plugin_protocol::messages::{ComponentKind, KeywordOptimizerInfo};
19use zerolaunch_plugin_protocol::ProtocolError;
20
21use crate::adapter::remote_component::{RemoteComponent, RemoteComponentKind};
22use crate::host_dispatch::HostCallHandler;
23use crate::info::PluginInfo;
24use crate::installed::{metadata_from_manifest, InstalledCache};
25use crate::process::force_kill_process;
26use crate::process::{PluginProcess, ProcessState};
27
28/// 重启回调类型别名:接收重新注册的适配器,
29/// 返回一个 future 用于把它们重新注册进 ConfigManager / SessionRouter。
30pub type RestartCallback =
31    Arc<dyn Fn(PluginRegistration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
32
33/// 崩溃即解注册回调:接收崩溃插件的旧注册包,通知 src-tauri 立即解注册
34/// CM/SR 中的组件并清理 HostApi 句柄。在崩溃处理的第一步调用,
35/// 与后续重启成败无关——放弃重启时不需要任何额外清理。
36pub type CrashCallback = Arc<dyn Fn(PluginRegistration) + Send + Sync>;
37
38/// 单个第三方插件的完整注册包。
39///
40/// 一个插件可以在 manifest 中声明提供多个组件(例如同时提供 DataSource 和 ActionExecutor),
41/// 所有组件统一存放在 `components` 中,由消费者按 `RemoteComponent::kind` 过滤后注册到
42/// 对应的子系统(Configurable / DataSource / ActionExecutor / Plugin)。
43#[derive(Clone, Debug)]
44pub struct PluginRegistration {
45    /// 插件唯一标识,对应 manifest.toml 中的 plugin.id
46    pub plugin_id: String,
47    /// 原始 manifest 全文快照
48    pub manifest: Manifest,
49    /// 插件实际安装目录(load 时的真实路径,不依赖目录名 == plugin_id 的隐式契约)。
50    /// 卸载/重载/覆盖安装按此路径定位,而非 plugins_dir.join(plugin_id) 反查。
51    pub plugin_dir: PathBuf,
52    /// 插件级元数据(宿主从清单构造,与各 Plugin 组件共享同一 Arc)。
53    /// 供 `get_plugin_info` 与已安装条目投影取插件级字段。
54    pub metadata: Arc<PluginMetadata>,
55
56    /// 该插件的所有远程组件。
57    /// 每个组件都是一个 `RemoteComponent`,同时实现多个 trait;
58    /// 消费者通过 `as_data_source()` / `as_action_executor()` / `as_plugin()` 按需转换。
59    pub components: Vec<Arc<RemoteComponent>>,
60}
61
62/// 插件运行状态(跨 IPC 序列化的运行状态契约)。
63///
64/// 由 plugin-host 从 `ProcessState` 映射产出,经 `PluginInfo.state` 下发,
65/// 消费方为前端插件管理页与 CLI 的插件列表展示;序列化为 snake_case 小写字符串
66/// (`starting` / `running` / `stopped` / `crashed` / `error` / `unknown`),
67/// 前端/CLI 按枚举值精确匹配。无负载变体:`ProcessState` 的 restarts/last_error
68/// 属诊断信息,经日志可观测,不进入 IPC 契约(Debug 字符串格式曾直接透传,已废弃)。
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum PluginRuntimeState {
72    /// 子进程启动中 —— 握手/组件发现阶段产生,前端非运行态展示。
73    #[serde(rename = "starting")]
74    Starting,
75    /// 运行中 —— 握手完成、watchdog 存活期间;内置插件恒为该态。
76    #[serde(rename = "running")]
77    Running,
78    /// 已停止 —— 优雅关闭或 auto_restart 关闭后退出。
79    #[serde(rename = "stopped")]
80    Stopped,
81    /// 已崩溃 —— 进程异常退出且重启失败(restarts 细节见日志)。
82    #[serde(rename = "crashed")]
83    Crashed,
84    /// 启动错误 —— 握手/协议失败(具体错误见日志)。
85    #[serde(rename = "error")]
86    Error,
87    /// 状态未知 —— 进程条目不存在(如崩溃重启间隙),默认值。
88    #[serde(rename = "unknown")]
89    #[default]
90    Unknown,
91}
92
93impl From<&ProcessState> for PluginRuntimeState {
94    fn from(state: &ProcessState) -> Self {
95        match state {
96            ProcessState::Starting => Self::Starting,
97            ProcessState::Running => Self::Running,
98            ProcessState::Stopped => Self::Stopped,
99            ProcessState::Crashed { .. } => Self::Crashed,
100            ProcessState::Error(_) => Self::Error,
101        }
102    }
103}
104
105/// 重启崩溃插件所需的上下文。
106struct PluginRestartContext {
107    manifest: Manifest,
108    plugin_dir: PathBuf,
109    host_call_handler: Arc<dyn HostCallHandler>,
110    /// 崩溃通知通道发送端副本:重新拉起的进程沿用同一通道。
111    crash_tx: mpsc::Sender<String>,
112    /// 重启成功后调用:供 src-tauri 用新适配器重新注册
113    /// ConfigManager 与 SessionRouter。
114    /// 返回 future 使调用方可避免 `block_on` 及其 `!Send` 风险。
115    on_restart: RestartCallback,
116    /// 崩溃即解注册回调:崩溃处理第一步调用(旧注册包交还 src-tauri 清理 CM/SR)。
117    on_crash: CrashCallback,
118    /// 持久化的重启计数器。每次重新生成前原子递增;
119    /// 当达到 manifest.runtime.max_restart 时不再尝试重启。
120    restart_count: AtomicU32,
121    /// 加载时的宿主界面语言,重启沿用;崩溃时宿主经 update_locale 更新为实时语言。
122    locale: RwLock<String>,
123}
124
125impl std::fmt::Debug for PluginRestartContext {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("PluginRestartContext")
128            .field("manifest", &self.manifest)
129            .field("plugin_dir", &self.plugin_dir)
130            .field("host_call_handler", &self.host_call_handler)
131            .field("crash_tx", &self.crash_tx)
132            .field("on_restart", &"<RestartCallback>")
133            .field("restart_count", &self.restart_count)
134            .finish()
135    }
136}
137
138/// 第三方插件子进程的顶层管理器。
139pub struct PluginHostManager {
140    /// 已加载插件的子进程映射。
141    pub processes: Arc<DashMap<String, Arc<PluginProcess>>>,
142    /// 已加载插件的组件注册包(含 DataSource / Executor / Plugin 适配器)。
143    pub plugins: Arc<DashMap<String, PluginRegistration>>,
144    /// 插件数据的根目录。
145    pub data_dir_root: PathBuf,
146    /// 插件 stderr 日志的根目录。
147    pub log_dir_root: PathBuf,
148    /// 插件安装目录。
149    pub(crate) plugins_dir: PathBuf,
150    /// 已安装插件的内存快照(磁盘清单的读模型;刷新点见 `InstalledCache` 文档)。
151    pub(crate) installed_cache: Arc<InstalledCache>,
152    /// 插件目录监听器;存活期即管理器存活期,drop 后监听线程随之退出。
153    /// 仅在启动期装配(`start_installed_watcher`)写入一次,之后只读。
154    pub(crate) installed_watcher: parking_lot::Mutex<Option<notify::RecommendedWatcher>>,
155    /// 每次加载插件时保存重启上下文,崩溃后可重新拉起。
156    restart_contexts: Arc<DashMap<String, Arc<PluginRestartContext>>>,
157    /// 序列化 load/teardown 的注册段(占位检查、spawn、登记、卸载清理)。
158    load_lock: Arc<AsyncMutex<()>>,
159    /// 内置组件 id 集合(冲突预检数据源之一)。
160    ///
161    /// 由 src-tauri 在启动时注入一次(内置组件注册完毕、第三方加载之前);
162    /// 前提是内置组件集合启动后稳定。RwLock 仅用于装配期一次性写入,
163    /// 预检读取为短临界区同步查询,不跨 .await。
164    builtin_component_ids: RwLock<HashSet<String>>,
165    /// 自引用 Arc:供内部崩溃处理任务(crash_loop/handle_crash)复用 load() 等 &self 方法。
166    ///
167    /// new() 构造时一次性写入,之后只读;不使用 RwLock 的原子自引模式
168    /// (AsyncDrop 竞态),OnceLock 在构造完成前无并发访问。
169    self_arc: OnceLock<Arc<Self>>,
170}
171
172/// 插件加载操作错误类型。
173#[derive(Debug, thiserror::Error)]
174pub enum PluginLoadError {
175    #[error("manifest error: {0}")]
176    Manifest(String),
177    #[error("protocol error: {0}")]
178    Protocol(#[from] ProtocolError),
179    #[error("plugin already loaded: {0}")]
180    AlreadyLoaded(String),
181    #[error("plugin not found: {0}")]
182    NotFound(String),
183    /// 组件 id 与已注册组件(内置或其他插件)冲突,插件加载被拒。
184    #[error(
185        "component id collision for plugin {plugin_id}: '{component_id}' is already registered"
186    )]
187    ComponentIdCollision {
188        /// 被拒绝加载的插件 id。
189        plugin_id: String,
190        /// 与已注册组件冲突的组件 id。
191        component_id: String,
192    },
193}
194
195impl PluginHostManager {
196    pub fn new(plugins_dir: PathBuf, data_dir_root: PathBuf, log_dir_root: PathBuf) -> Arc<Self> {
197        // 启动时全量扫描一次安装目录,之后一律读内存快照(监听/操作链路负责刷新)。
198        let installed_cache = Arc::new(InstalledCache::load(plugins_dir.clone()));
199        let mgr = Arc::new(Self {
200            processes: Arc::new(DashMap::new()),
201            plugins: Arc::new(DashMap::new()),
202            data_dir_root,
203            log_dir_root,
204            plugins_dir,
205            installed_cache,
206            installed_watcher: parking_lot::Mutex::new(None),
207            restart_contexts: Arc::new(DashMap::new()),
208            load_lock: Arc::new(AsyncMutex::new(())),
209            builtin_component_ids: RwLock::new(HashSet::new()),
210            self_arc: OnceLock::new(),
211        });
212        if mgr.self_arc.set(Arc::clone(&mgr)).is_err() {
213            panic!("PluginHostManager self arc already set");
214        }
215        mgr
216    }
217
218    /// 注入内置组件 id 集合(冲突预检数据源)。
219    ///
220    /// 由 src-tauri 在内置组件注册完毕后调用一次;此后内置组件集合
221    /// 启动期稳定,不再更新。预检据此识别「与内置组件撞 id」。
222    pub fn set_builtin_component_ids(&self, ids: HashSet<String>) {
223        *self.builtin_component_ids.write() = ids;
224    }
225
226    /// 启动崩溃处理任务。
227    ///
228    /// 同步包装:async 闭包在独立函数中构造并立即 move 进 tokio::spawn,
229    /// 避免 load 的 future 状态机保守保留该闭包(其内部 await crash_loop →
230    /// handle_crash → load 形成间接递归),导致 Send 推断失败。
231    fn spawn_crash_loop(&self, crash_rx: mpsc::Receiver<String>) {
232        let mgr = Arc::clone(self.self_arc.get().expect("self arc set in load"));
233        tokio::spawn(async move {
234            crash_loop(mgr, crash_rx).await;
235        });
236    }
237
238    /// 从包含 manifest.toml 的目录加载一个插件。
239    ///
240    /// `on_restart` 存入重启上下文,崩溃重启成功后被调用,
241    /// 供调用方(src-tauri)重新注册新适配器。
242    /// `on_crash` 在崩溃检测到的那一刻(任何重启尝试之前)以旧注册包调用,
243    /// 供调用方解注册 CM/SR 中的过期组件。
244    /// `restart_count` 为已发生的重启次数(初次加载传 0),用于延续崩溃恢复计数。
245    /// `locale` 为宿主当前界面语言,随 initialize 握手下发插件进程。
246    pub async fn load(
247        &self,
248        plugin_dir: &Path,
249        host_call_handler: Arc<dyn HostCallHandler>,
250        on_restart: RestartCallback,
251        on_crash: CrashCallback,
252        restart_count: u32,
253        locale: &str,
254    ) -> Result<PluginRegistration, PluginLoadError> {
255        let manifest_path = plugin_dir.join("manifest.toml");
256        let manifest_bytes = std::fs::read_to_string(&manifest_path)
257            .map_err(|e| PluginLoadError::Manifest(format!("cannot read manifest.toml: {}", e)))?;
258
259        let manifest: Manifest = toml::from_str(&manifest_bytes)
260            .map_err(|e| PluginLoadError::Manifest(format!("invalid manifest: {}", e)))?;
261
262        // 校验 manifest
263        validate_manifest(&manifest, plugin_dir)?;
264
265        let plugin_id = manifest.plugin.id.clone();
266
267        // 序列化整个注册段(spawn/登记/冲突预检),load 与 teardown 互斥。
268        let _guard = self.load_lock.lock().await;
269
270        // 查重:已加载则拒绝
271        if self.processes.contains_key(&plugin_id) {
272            return Err(PluginLoadError::AlreadyLoaded(plugin_id));
273        }
274
275        let data_dir = self.data_dir_root.join(&plugin_id);
276        let log_dir = self.log_dir_root.clone();
277
278        // 确保数据目录存在
279        if let Err(e) = std::fs::create_dir_all(&data_dir) {
280            warn!(
281                "Failed to create plugin data dir {}: {}",
282                data_dir.display(),
283                e
284            );
285        }
286        if let Err(e) = std::fs::create_dir_all(&log_dir) {
287            warn!(
288                "Failed to create plugin log dir {}: {}",
289                log_dir.display(),
290                e
291            );
292        }
293
294        info!("Loading plugin {} from {}", plugin_id, plugin_dir.display());
295
296        // 创建持久崩溃通知通道:管理器持有接收端,发送端跨多次重启共享。
297        let (crash_tx, crash_rx) = mpsc::channel::<String>(4);
298
299        // 启动子进程并完成握手;失败时直接返回(尚未登记任何条目)
300        let process = match PluginProcess::spawn(
301            &manifest,
302            plugin_dir,
303            &data_dir,
304            &log_dir,
305            host_call_handler.clone(),
306            crash_tx.clone(),
307            restart_count,
308            locale,
309        )
310        .await
311        {
312            Ok(p) => p,
313            Err(e) => {
314                // spawn 失败:尚未登记任何条目,无需额外清理
315                return Err(e.into());
316            }
317        };
318
319        // 在把进程移入 Arc 之前取出 client
320        let client = process.client.clone();
321
322        // 保存重启上下文,供崩溃恢复使用
323        self.restart_contexts.insert(
324            plugin_id.clone(),
325            Arc::new(PluginRestartContext {
326                manifest: manifest.clone(),
327                plugin_dir: plugin_dir.to_path_buf(),
328                host_call_handler,
329                crash_tx,
330                on_restart,
331                on_crash,
332                restart_count: AtomicU32::new(restart_count),
333                locale: RwLock::new(locale.to_string()),
334            }),
335        );
336
337        // 启动崩溃监听任务:崩溃时重新加载该插件
338        self.spawn_crash_loop(crash_rx);
339
340        // 在发现组件之前先登记进程,闭合重启窗口:
341        // 若插件在 discover_components() 期间崩溃,watchdog 能找到进程条目,
342        // 且 crash_loop 会正确处理重新拉起。
343        let process = Arc::new(process);
344        self.processes.insert(plugin_id.clone(), process.clone());
345
346        // 发现组件(经 Arc 调用——discover_components 取 &self)
347        let init_result = match process.discover_components().await {
348            Ok(result) => result,
349            Err(e) => {
350                self.shutdown_loaded_process(&plugin_id).await;
351                return Err(PluginLoadError::Protocol(e));
352            }
353        };
354
355        // 从发现的组件构建适配器
356        let adapters = build_components(&plugin_id, plugin_dir, &manifest, client, &init_result);
357
358        // 冲突预检:组件 id 清单来自插件运行时自报(get_components RPC),
359        // 只能在 spawn 之后获得。这里在登记 hm.plugins 之前校验,
360        // 任一组件 id 与已加载插件或内置组件冲突则整包拒绝:
361        // 关闭子进程并清理全部登记,避免「进程残留 + 半提交注册」。
362        // 自身组件尚未登记进 plugins map,自碰撞在结构上不可能。
363        let component_ids = adapters
364            .components
365            .iter()
366            .map(|c| c.component_id().to_string())
367            .collect::<Vec<_>>();
368        if let Some(collision) =
369            find_component_id_collision(&component_ids, |id| self.component_id_is_taken(id))
370        {
371            error!(
372                "拒绝加载插件 {}:组件 id '{}' 已被其他已注册组件占用,清理进程并放弃加载",
373                plugin_id, collision
374            );
375            self.shutdown_loaded_process(&plugin_id).await;
376            return Err(PluginLoadError::ComponentIdCollision {
377                plugin_id,
378                component_id: collision,
379            });
380        }
381
382        // 先 clone 再登记,返回值无需二次 DashMap 查找 + 6 次字段克隆。
383        let registered = adapters.clone();
384        self.plugins.insert(plugin_id.clone(), adapters);
385        Ok(registered)
386    }
387
388    /// 返回插件安装目录(显式存储,非派生)。
389    pub fn plugins_dir(&self) -> &Path {
390        &self.plugins_dir
391    }
392
393    /// 组件 id 占用查询:已加载第三方插件的组件 或 内置组件集合命中即视为占用。
394    ///
395    /// 仅用于 load 冲突预检(同步短临界区,不跨 .await);
396    /// 查询时自身组件尚未登记进 plugins map,故不会误判自碰撞。
397    fn component_id_is_taken(&self, id: &str) -> bool {
398        let taken_by_plugin = self
399            .plugins
400            .iter()
401            .any(|e| e.value().components.iter().any(|c| c.component_id() == id));
402        taken_by_plugin || self.builtin_component_ids.read().contains(id)
403    }
404
405    /// 卸载插件:关闭子进程、移除全部登记并删除诊断日志(含轮转 .log.1)。
406    /// 与 teardown(加载失败清理)的区别:teardown 保留日志供故障溯源。
407    pub async fn unload(&self, plugin_id: &str) -> Result<(), PluginLoadError> {
408        info!("Unloading plugin {}", plugin_id);
409        self.teardown(plugin_id).await;
410        // 显式卸载才删日志:失败清理路径保留诊断日志,便于故障溯源
411        for suffix in ["", ".1"] {
412            let log_file = self
413                .log_dir_root
414                .join(format!("{}.log{}", plugin_id, suffix));
415            let _ = std::fs::remove_file(&log_file);
416        }
417        Ok(())
418    }
419
420    /// 关闭插件子进程并从注册表移除登记。
421    ///
422    /// 与 load 共用 load_lock,卸载与加载互斥(load 失败清理不调本函数,
423    /// 改调 shutdown_loaded_process——AsyncMutex 非重入,持锁时调用会死锁)。
424    async fn teardown(&self, plugin_id: &str) {
425        let _guard = self.load_lock.lock().await;
426        // 移除重启上下文并注销该插件的订阅回调与定时器(推送通道随进程关闭失效)。
427        if let Some((_, ctx)) = self.restart_contexts.remove(plugin_id) {
428            ctx.host_call_handler.detach_event_sink();
429        }
430        self.shutdown_loaded_process(plugin_id).await;
431        self.plugins.remove(plugin_id);
432    }
433
434    /// 关闭已加载插件的子进程并移除进程登记。
435    /// shutdown() 独占 Arc;若 try_unwrap 失败(进程 Arc 在其他处被克隆),
436    /// 先标记 Stopped 阻止 watchdog 重启,再关闭 stdin 并强杀进程。
437    async fn shutdown_loaded_process(&self, plugin_id: &str) {
438        if let Some((_, proc)) = self.processes.remove(plugin_id) {
439            match Arc::try_unwrap(proc) {
440                Ok(process) => process.shutdown(std::time::Duration::from_secs(5)).await,
441                Err(arc) => {
442                    warn!(
443                        "Plugin {} process Arc has {} strong references; forcing kill. \n                         This may indicate a leaked clone of the process handle.",
444                        plugin_id,
445                        Arc::strong_count(&arc)
446                    );
447                    arc.state.write().clone_from(&ProcessState::Stopped);
448                    arc.client.close_transport();
449                    if let Some(pid) = arc.pid {
450                        force_kill_process(pid);
451                    }
452                }
453            }
454        }
455    }
456
457    /// 更新插件重启上下文中的语言快照。
458    ///
459    /// 宿主在崩溃回调中调用(崩溃时刻取最新语言),保证崩溃重启的
460    /// initialize 握手携带最新 locale,而非首次加载时的快照。
461    /// 插件不存在(正常卸载等)时静默。
462    pub fn update_locale(&self, plugin_id: &str, locale: &str) {
463        if let Some(ctx) = self.restart_contexts.get_mut(plugin_id) {
464            *ctx.locale.write() = locale.to_string();
465        }
466    }
467
468    /// 单个插件的运行时条目(按 id 直查,不遍历全量列表)。
469    ///
470    /// 返回 None 表示该插件当前未加载;调用方据此区分运行态与「已安装未加载」。
471    /// 启用态 = 全部已注册组件均启用(无组件时视为启用)。
472    pub fn get_plugin_info(
473        &self,
474        plugin_id: &str,
475        is_component_enabled: impl Fn(&str) -> bool,
476    ) -> Option<PluginInfo> {
477        let entry = self.plugins.get(plugin_id)?;
478        let registration = entry.value();
479        let process_state = self
480            .processes
481            .get(plugin_id)
482            .map(|p| PluginRuntimeState::from(&*p.state.read()))
483            .unwrap_or_default();
484        let component_ids: Vec<String> = registration
485            .components
486            .iter()
487            .map(|c| c.component_id().to_string())
488            .collect();
489        let enabled = component_ids.iter().all(|id| is_component_enabled(id));
490        Some(PluginInfo::from_metadata(
491            &registration.metadata,
492            process_state,
493            component_ids,
494            enabled,
495        ))
496    }
497}
498
499// ─── 辅助函数 ─────────────────────────────────────────────────────
500
501fn validate_manifest(manifest: &Manifest, plugin_dir: &Path) -> Result<(), PluginLoadError> {
502    let id = &manifest.plugin.id;
503
504    // 校验插件 ID 格式(正则只编译一次)
505    static PLUGIN_ID_RE: OnceLock<regex::Regex> = OnceLock::new();
506    let re = PLUGIN_ID_RE.get_or_init(|| {
507        regex::Regex::new(zerolaunch_plugin_protocol::manifest::PLUGIN_ID_RE).unwrap()
508    });
509    if !re.is_match(id) {
510        return Err(PluginLoadError::Manifest(format!(
511            "invalid plugin id '{}': must match reverse domain",
512            id
513        )));
514    }
515
516    // 校验版本
517    if semver::Version::parse(&manifest.plugin.version).is_err() {
518        return Err(PluginLoadError::Manifest(format!(
519            "invalid plugin version '{}'",
520            manifest.plugin.version
521        )));
522    }
523
524    // 校验必需的 provides 声明
525    if manifest.components.provides.is_empty() {
526        return Err(PluginLoadError::Manifest(
527            "components.provides must have at least one entry".into(),
528        ));
529    }
530
531    for p in &manifest.components.provides {
532        if !zerolaunch_plugin_protocol::manifest::REQUIRED_PROVIDES_VALUES.contains(&p.as_str()) {
533            return Err(PluginLoadError::Manifest(format!(
534                "unknown component type '{}'",
535                p
536            )));
537        }
538    }
539
540    // Validate command path does not escape the plugin directory
541    let cmd_path = plugin_dir.join(&manifest.runtime.command);
542    let canonical_cmd = cmd_path
543        .canonicalize()
544        .map_err(|e| PluginLoadError::Manifest(format!("command not found: {}", e)))?;
545    let canonical_plugin_dir = plugin_dir
546        .canonicalize()
547        .map_err(|e| PluginLoadError::Manifest(format!("plugin dir canonicalize: {}", e)))?;
548    if !canonical_cmd.starts_with(&canonical_plugin_dir) {
549        return Err(PluginLoadError::Manifest(
550            "command path escapes plugin directory".into(),
551        ));
552    }
553
554    // Validate icon path does not escape the plugin directory (if declared).
555    // 文件缺失/不可读不阻断加载(图标可选,由 read_plugin_icon 降级为 None);
556    // 仅当文件存在且能解析时校验逃逸(安全项硬拒绝)。
557    if let Some(icon) = &manifest.icon {
558        if let Ok(canonical_icon) = plugin_dir.join(&icon.path).canonicalize() {
559            if !canonical_icon.starts_with(&canonical_plugin_dir) {
560                return Err(PluginLoadError::Manifest(
561                    "icon path escapes plugin directory".into(),
562                ));
563            }
564        }
565    }
566
567    Ok(())
568}
569
570// ─── 辅助函数:按 component_id 从 Vec<(String, T)> 中查找值 ───
571
572/// 从 `Vec<(String, T)>` 中按 component_id 查找值,找不到返回 default。
573fn find_by_id<T: Clone + Default>(map: &[(String, T)], component_id: &str) -> T {
574    map.iter()
575        .find(|(id, _)| id == component_id)
576        .map(|(_, v)| v.clone())
577        .unwrap_or_default()
578}
579
580/// 从 settings_values 中查找,找不到返回 Null(区别于 default)。
581fn find_settings_value(
582    values: &[(String, serde_json::Value)],
583    component_id: &str,
584) -> serde_json::Value {
585    values
586        .iter()
587        .find(|(id, _)| id == component_id)
588        .map(|(_, v)| v.clone())
589        .unwrap_or(serde_json::Value::Null)
590}
591
592// ─── build_components ───────────────────────────────────────────────
593
594/// 从组件 id 列表中找出第一个已被占用(与已加载插件或内置组件冲突)的 id。
595///
596/// 仅在本模块加载(load)预检中使用;返回 None 表示全部 id 可用。
597/// 独立成纯函数便于单测。
598fn find_component_id_collision(
599    component_ids: &[String],
600    component_id_taken: impl Fn(&str) -> bool,
601) -> Option<String> {
602    component_ids
603        .iter()
604        .find(|id| component_id_taken(id))
605        .cloned()
606}
607
608/// 从 InitResult 构建所有 `RemoteComponent`。
609///
610/// 每个组件统一构造为 `RemoteComponent`;种类专属数据(target_types、result_actions、
611/// metadata)根据 `ComponentKind` 放入 `RemoteComponentKind` 对应变体。
612fn build_components(
613    plugin_id: &str,
614    plugin_dir: &Path,
615    manifest: &Manifest,
616    client: Arc<crate::client::JsonRpcClient>,
617    init_result: &crate::process::InitResult,
618) -> PluginRegistration {
619    // 插件级元数据由宿主从清单构造;包 Arc 后与各 Plugin 组件共享同一份。
620    let plugin_metadata = Arc::new(metadata_from_manifest(plugin_dir, manifest));
621
622    let components: Vec<Arc<RemoteComponent>> = init_result
623        .components
624        .iter()
625        .map(|comp| {
626            let schema = find_by_id(&init_result.settings_schemas, &comp.component_id);
627            let settings = find_settings_value(&init_result.settings_values, &comp.component_id);
628            let config_actions = find_by_id(&init_result.config_actions_map, &comp.component_id);
629            let default_enabled = find_by_id(&init_result.default_enabled_map, &comp.component_id);
630
631            // 组件优先级为 u32(协议层已统一),无负数可能,无需钳制
632            let priority = comp.priority;
633
634            let kind = match &comp.kind {
635                ComponentKind::Plugin => {
636                    let interaction_policy =
637                        find_by_id(&init_result.interaction_policy_map, &comp.component_id);
638                    RemoteComponentKind::Plugin {
639                        metadata: Arc::clone(&plugin_metadata),
640                        interaction_policy: parking_lot::RwLock::new(interaction_policy),
641                    }
642                }
643                ComponentKind::DataSource => RemoteComponentKind::DataSource,
644                ComponentKind::ActionExecutor { target_types } => {
645                    let result_actions =
646                        find_by_id(&init_result.executor_actions_map, &comp.component_id);
647                    RemoteComponentKind::ActionExecutor {
648                        target_types: target_types.clone(),
649                        result_actions,
650                    }
651                }
652                ComponentKind::SearchEngine => RemoteComponentKind::SearchEngine,
653                ComponentKind::ScoreBooster => RemoteComponentKind::ScoreBooster,
654                ComponentKind::KeywordOptimizer => {
655                    // RPC 失败时用协议侧兜底构造(Refined 精化层,priority 取组件自身)。
656                    let info = init_result
657                        .keyword_optimizer_info_map
658                        .iter()
659                        .find(|(id, _)| id == &comp.component_id)
660                        .map(|(_, v)| v.clone())
661                        .unwrap_or_else(|| KeywordOptimizerInfo::fallback(priority));
662                    RemoteComponentKind::KeywordOptimizer {
663                        info: parking_lot::RwLock::new(info),
664                    }
665                }
666                ComponentKind::KeywordInjector => RemoteComponentKind::KeywordInjector,
667            };
668
669            Arc::new(RemoteComponent::new(
670                comp.component_id.clone(),
671                comp.component_name.clone(),
672                comp.component_description.clone(),
673                comp.component_type,
674                priority,
675                client.clone(),
676                schema,
677                settings,
678                config_actions,
679                default_enabled,
680                kind,
681            ))
682        })
683        .collect();
684
685    let registration = PluginRegistration {
686        plugin_id: plugin_id.to_string(),
687        manifest: manifest.clone(),
688        // 记录真实安装目录:卸载/重载按此定位,不依赖目录名 == plugin_id 契约
689        plugin_dir: plugin_dir.to_path_buf(),
690        metadata: plugin_metadata,
691        components,
692    };
693
694    #[cfg(debug_assertions)]
695    {
696        // 唯一源断言:所有 Plugin 组件必须与 registration.metadata 共享同一 Arc。
697        // 若未来某处改为独立 clone,此处立即失败,防止两副本单边漂移。
698        for comp in &registration.components {
699            if let RemoteComponentKind::Plugin { metadata, .. } = &comp.kind {
700                debug_assert!(
701                    Arc::ptr_eq(metadata, &registration.metadata),
702                    "组件元数据与 PluginRegistration.metadata 必须同源共享(Arc::clone)"
703                );
704            }
705        }
706    }
707
708    registration
709}
710
711/// 崩溃处理主循环:监听 crash channel,串行处理每个崩溃事件。
712///
713/// 每次 load 创建新 channel 时 spawn 一个;channel 关闭(进程与上下文均销毁)即退出。
714async fn crash_loop(mgr: Arc<PluginHostManager>, mut crash_rx: mpsc::Receiver<String>) {
715    while let Some(plugin_id) = crash_rx.recv().await {
716        handle_crash(&mgr, &plugin_id).await;
717    }
718}
719
720/// 处理单个插件崩溃:崩溃即解注册 → 计数/上限检查 → 复用 load() 重启。
721///
722/// 旧注册包在第一步交还 src-tauri 解注册(CM/SR/host_api),
723/// 之后无论重启成败都不再有残留,无需任何「放弃清理」路径。
724async fn handle_crash(mgr: &PluginHostManager, plugin_id: &str) {
725    warn!("Watchdog triggered restart for plugin: {}", plugin_id);
726
727    // 崩溃即解注册:从登记中移除旧进程与旧注册包,并把旧注册包
728    // 立即交还 src-tauri 清理 CM/SR/host_api。自身组件因此不再
729    // 出现在预检数据源中,后续 load 的重启预检不会自碰撞。
730    let prev = mgr.plugins.remove(plugin_id);
731    mgr.processes.remove(plugin_id);
732
733    // 取出 owned Arc:DashMap 读守卫仅在闭包内存活,map 返回即释放,
734    // 不跨后续 load(可能耗时数秒)的 .await。
735    let Some(ctx) = mgr
736        .restart_contexts
737        .get(plugin_id)
738        .map(|r| Arc::clone(r.value()))
739    else {
740        warn!(
741            "Crash notification for plugin '{}' but no restart context found — \
742             plugin may have been unloaded concurrently",
743            plugin_id
744        );
745        return;
746    };
747    // 先 detach:on_crash 会注销 HostApi 句柄,之后无从清理。
748    ctx.host_call_handler.detach_event_sink();
749
750    if let Some((_, prev)) = prev {
751        (ctx.on_crash)(prev);
752    }
753    // 原子递增持久化的重启计数器,并检查是否超出 max_restart。
754    // 这是**唯一**追踪重启次数的地方——不在看门狗或
755    // PluginProcess 中(它们每次重启都会被替换)。
756    let new_count = ctx.restart_count.fetch_add(1, Ordering::SeqCst) + 1;
757    let max_restart = ctx.manifest.runtime.max_restart;
758    if new_count > max_restart {
759        error!(
760            "Plugin {} exceeded max restarts ({}/{}) — not restarting",
761            plugin_id, new_count, max_restart
762        );
763        mgr.restart_contexts.remove(plugin_id);
764        return;
765    }
766
767    // 复用 load():spawn/discover/预检/登记与初次加载同一路径,
768    // 失败路径由 load 自行清理(冲突/discover 失败会 teardown),
769    // 此处只需在 load 失败时移除旧上下文。
770    // 读取最新语言并克隆:RwLock guard 不得跨 .await 持有
771    // (load 内部会 await spawn 握手)。
772    let locale = ctx.locale.read().clone();
773    match mgr
774        .load(
775            &ctx.plugin_dir,
776            ctx.host_call_handler.clone(),
777            ctx.on_restart.clone(),
778            ctx.on_crash.clone(),
779            new_count,
780            &locale,
781        )
782        .await
783    {
784        Ok(registered) => {
785            // 重启成功:通知 src-tauri 注册新适配器(旧组件已在崩溃第一步解注册)
786            (ctx.on_restart)(registered).await;
787            info!("Plugin {} successfully restarted", plugin_id);
788        }
789        Err(e) => {
790            error!("Failed to restart plugin {}: {}", plugin_id, e);
791            mgr.restart_contexts.remove(plugin_id);
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::find_component_id_collision;
799
800    /// 冲突预检契约:返回第一个被占用的组件 id;全部可用时返回 None。
801    #[test]
802    fn collision_check_finds_first_taken_id() {
803        let ids = vec!["free".to_string(), "taken".to_string(), "other".to_string()];
804        let taken = |id: &str| id == "taken";
805        assert_eq!(
806            find_component_id_collision(&ids, taken),
807            Some("taken".to_string())
808        );
809    }
810
811    /// 无冲突时返回 None,加载预检可放行。
812    #[test]
813    fn collision_check_returns_none_when_all_free() {
814        let ids = vec!["a".to_string(), "b".to_string()];
815        assert_eq!(find_component_id_collision(&ids, |_| false), None);
816    }
817}