Skip to main content

zerolaunch_plugin_host/
installed.rs

1//! 已安装插件的解析、内存快照与投影。
2//!
3//! 安装事实的唯一权威是 plugins 目录下的清单:目录定位、清单与元数据在扫描时
4//! 各解析一次,供列表、详情、重载、卸载与覆盖安装共用。未加载(启动/重载失败)
5//! 的插件同样可解析,从其投影出 error 态条目,界面不会因加载失败而丢条目。
6//!
7//! 解析结果落在 [`InstalledCache`](内存读模型),刷新点只有三处:启动扫描、
8//! 插件目录监听线程、安装/卸载/重载动作完成后的显式刷新。读接口一律只读内存,
9//! 不再在每次查询时扫盘。
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use notify::{RecursiveMode, Watcher};
17use parking_lot::RwLock;
18use tracing::{info, warn};
19use zerolaunch_plugin_api::plugin::{PluginKind, PluginMetadata, PluginMode};
20use zerolaunch_plugin_protocol::Manifest;
21
22use crate::icon::{mime_from_extension, to_data_url, MAX_ICON_BYTES};
23use crate::info::PluginInfo;
24use crate::manager::{PluginHostManager, PluginRuntimeState};
25
26/// 已安装插件 —— 安装事实的唯一解析结果。
27///
28/// 目录名可异于清单 id,所有目录操作以 `dir` 为准;`metadata` 在解析时一次算好
29/// (读取图标文件等代价不重复支付)。
30pub struct InstalledPlugin {
31    /// 真实安装目录。
32    pub dir: PathBuf,
33    /// 已解析的清单。
34    pub manifest: Manifest,
35    /// 由清单派生的插件级元数据。
36    pub metadata: PluginMetadata,
37}
38
39impl InstalledPlugin {
40    /// 从安装目录解析插件:读取 manifest.toml 并派生元数据。
41    /// 参数:dir - 安装目录。
42    /// 返回:解析成功返回记录;清单缺失或非法返回 None(调用方告警跳过)。
43    pub fn from_dir(dir: &Path) -> Option<Self> {
44        let manifest_path = dir.join("manifest.toml");
45        let text = std::fs::read_to_string(&manifest_path).ok()?;
46        let manifest: Manifest = match toml::from_str(&text) {
47            Ok(manifest) => manifest,
48            Err(e) => {
49                warn!("插件清单解析失败,跳过 {}: {}", manifest_path.display(), e);
50                return None;
51            }
52        };
53        let metadata = metadata_from_manifest(dir, &manifest);
54        Some(Self {
55            dir: dir.to_path_buf(),
56            manifest,
57            metadata,
58        })
59    }
60}
61
62/// 已安装插件快照 —— plugins 目录清单(唯一权威)的内存读模型。
63///
64/// 仅由本文件与 `manager.rs` 使用;外部(src-tauri)经 `PluginHostManager` 的
65/// `installed` / `installed_snapshot` 读接口访问,不得自行扫描磁盘或另存一份。
66/// 内部可变性用 `RwLock`:读(每次列表/详情)远多于写(启动一次 + 目录变更)。
67pub struct InstalledCache {
68    /// 插件安装根目录(扫描对象)。
69    plugins_dir: PathBuf,
70    /// 已解析的安装记录;读方只做 Arc 克隆,不触发磁盘 IO。
71    snapshot: RwLock<Vec<Arc<InstalledPlugin>>>,
72}
73
74impl InstalledCache {
75    /// 构造并执行首次全量扫描。
76    /// 参数:plugins_dir - 插件安装根目录(不存在时快照为空)。
77    pub(crate) fn load(plugins_dir: PathBuf) -> Self {
78        let cache = Self {
79            plugins_dir,
80            snapshot: RwLock::new(Vec::new()),
81        };
82        cache.refresh();
83        cache
84    }
85
86    /// 重扫安装目录并原子替换快照。
87    fn refresh(&self) {
88        let snapshot = scan_installed(&self.plugins_dir);
89        *self.snapshot.write() = snapshot;
90    }
91
92    /// 读取快照(Arc 克隆,无磁盘 IO)。
93    fn list(&self) -> Vec<Arc<InstalledPlugin>> {
94        self.snapshot.read().clone()
95    }
96
97    /// 按清单 id 在快照中定位安装记录。
98    /// 参数:plugin_id - 清单 id。
99    /// 返回:命中返回记录,未安装返回 None。
100    fn get(&self, plugin_id: &str) -> Option<Arc<InstalledPlugin>> {
101        self.snapshot
102            .read()
103            .iter()
104            .find(|plugin| plugin.metadata.id == plugin_id)
105            .cloned()
106    }
107}
108
109/// 全量扫描安装目录:解析每个含 manifest.toml 的子目录。
110/// 仅限本文件使用(`InstalledCache::refresh` 的唯一扫描实现)。
111fn scan_installed(root: &Path) -> Vec<Arc<InstalledPlugin>> {
112    let Ok(entries) = std::fs::read_dir(root) else {
113        return Vec::new();
114    };
115    entries
116        .flatten()
117        .map(|entry| entry.path())
118        .filter(|path| path.is_dir() && path.join("manifest.toml").is_file())
119        .filter_map(|dir| InstalledPlugin::from_dir(&dir).map(Arc::new))
120        .collect()
121}
122
123/// 事件是否需要触发快照重扫:清单/插件目录变更、`need_rescan`(丢事件)标记,
124/// 或监听后端报错(此时无法确定漏掉了哪些变更,只能重扫补齐)。
125/// 仅限本文件使用(监听线程的事件过滤条件)。
126fn event_triggers_refresh(event: notify::Result<notify::Event>, root: &Path) -> bool {
127    match event {
128        Ok(event) => {
129            event.need_rescan() || event.paths.iter().any(|path| affects_installed(path, root))
130        }
131        Err(_) => true,
132    }
133}
134
135/// 目录事件是否影响安装事实:插件目录本身的增删改名,或任意层级的清单写入。
136/// 仅限本文件使用(监听线程的过滤条件)。
137fn affects_installed(path: &Path, root: &Path) -> bool {
138    let Ok(relative) = path.strip_prefix(root) else {
139        return false;
140    };
141    relative.components().count() <= 1
142        || path.file_name().is_some_and(|name| name == "manifest.toml")
143}
144
145impl PluginHostManager {
146    /// 枚举包含 manifest.toml 的插件目录(不解析清单)。
147    /// 返回:目录列表;安装根目录不存在时为空。
148    ///
149    /// 仅用于启动期加载(`load_all_third_party`);列表/详情一律读内存快照。
150    pub fn scan_dirs(&self) -> Vec<PathBuf> {
151        let mut found = Vec::new();
152        if let Ok(entries) = std::fs::read_dir(self.plugins_dir()) {
153            for entry in entries.flatten() {
154                let path = entry.path();
155                if path.is_dir() && path.join("manifest.toml").exists() {
156                    found.push(path);
157                }
158            }
159        }
160        found
161    }
162
163    /// 已安装插件的内存快照(Arc 克隆,无磁盘 IO)。
164    pub fn installed_snapshot(&self) -> Vec<Arc<InstalledPlugin>> {
165        self.installed_cache.list()
166    }
167
168    /// 立即重扫安装目录并替换快照。
169    ///
170    /// 安装/卸载/重载链路在动作完成后调用,保证紧随其后的 IPC 读到新事实;
171    /// 监听线程同样只调用本方法,刷新逻辑唯一。
172    pub fn refresh_installed(&self) {
173        self.installed_cache.refresh();
174    }
175
176    /// 按清单 id 定位安装记录:已加载取注册包快照,否则读内存安装快照。
177    /// 参数:plugin_id - 插件 id。
178    /// 返回:安装记录;未安装(或快照尚未刷新到)返回 None。
179    pub fn installed(&self, plugin_id: &str) -> Option<Arc<InstalledPlugin>> {
180        if let Some(registration) = self.plugins.get(plugin_id) {
181            return Some(Arc::new(InstalledPlugin {
182                dir: registration.plugin_dir.clone(),
183                manifest: registration.manifest.clone(),
184                metadata: (*registration.metadata).clone(),
185            }));
186        }
187        self.installed_cache.get(plugin_id)
188    }
189
190    /// 启动插件目录监听:目录变更后异步重扫安装快照。
191    ///
192    /// 幂等:已启动直接返回 Ok。最终一致(事件合并窗口约 300ms),覆盖用户手动
193    /// 拷贝/删除插件目录的场景;监听失败只降级为「只在动作后刷新」,不阻断启动。
194    /// 返回:启动成功 Ok(()),创建目录或监听失败返回错误。
195    pub fn start_installed_watcher(&self) -> Result<()> {
196        let mut slot = self.installed_watcher.lock();
197        if slot.is_some() {
198            return Ok(());
199        }
200
201        let root = self.plugins_dir.clone();
202        // 监听要求目录存在;插件根目录的创建是幂等的。
203        std::fs::create_dir_all(&root)
204            .with_context(|| format!("无法创建插件目录: {}", root.display()))?;
205
206        let (tx, rx) = std::sync::mpsc::channel::<()>();
207        let filter_root = root.clone();
208        let mut watcher =
209            notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
210                // 报错或 need_rescan(递归监听缓冲溢出等)意味着无法确定漏掉了哪些变更:
211                // 只能重扫补齐,否则快照会永久停在旧事实、直到下一次有事件进来。
212                if event_triggers_refresh(event, &filter_root) {
213                    let _ = tx.send(());
214                }
215            })
216            .with_context(|| format!("无法监听插件目录: {}", root.display()))?;
217        watcher
218            .watch(&root, RecursiveMode::Recursive)
219            .with_context(|| format!("无法监听插件目录: {}", root.display()))?;
220
221        // 事件合并:连续事件静默一个窗口后只重扫一次,避免整包安装时逐文件重扫。
222        // 线程只持有快照缓存(不持有管理器),监听器 drop 后发送端关闭,线程退出。
223        let cache = Arc::clone(&self.installed_cache);
224        std::thread::Builder::new()
225            .name("plugin-install-watch".to_string())
226            .spawn(move || {
227                while rx.recv().is_ok() {
228                    while rx.recv_timeout(Duration::from_millis(300)).is_ok() {}
229                    cache.refresh();
230                }
231            })
232            .with_context(|| "无法启动插件目录监听线程")?;
233
234        *slot = Some(watcher);
235        info!("插件目录监听已启动: {}", root.display());
236        Ok(())
237    }
238
239    /// 安装记录 → 展示条目:已加载取运行时注册,未加载呈现 error 态。
240    /// 参数:installed - 安装记录;is_component_enabled - 组件启用态谓词(由调用方提供)。
241    /// 返回:IPC 展示条目(未加载时组件为空、enabled 为 false)。
242    pub fn to_info(
243        &self,
244        installed: &InstalledPlugin,
245        is_component_enabled: impl Fn(&str) -> bool,
246    ) -> PluginInfo {
247        self.get_plugin_info(&installed.metadata.id, is_component_enabled)
248            .unwrap_or_else(|| {
249                PluginInfo::from_metadata(&installed.metadata, PluginRuntimeState::Error, false)
250            })
251    }
252}
253
254/// 由清单构造插件级元数据。
255///
256/// 加载期(构建注册包)与磁盘条目(未加载插件)共用本函数,保证图标、热键、
257/// 形态与优先级只在同一处从 manifest 派生。
258/// 参数:plugin_dir - 安装目录(读取 [icon] 文件用);manifest - 已解析清单。
259/// 返回:插件级元数据,kind 恒为 ThirdParty。
260pub(crate) fn metadata_from_manifest(plugin_dir: &Path, manifest: &Manifest) -> PluginMetadata {
261    let metadata = PluginMetadata {
262        id: manifest.plugin.id.clone(),
263        name: manifest.plugin.name.clone(),
264        version: manifest.plugin.version.clone(),
265        description: manifest.plugin.description.clone(),
266        author: manifest.plugin.author.clone(),
267        trigger_keywords: manifest.plugin.trigger_keywords.clone(),
268        trigger_description: manifest.plugin.trigger_description.clone(),
269        supported_os: manifest.plugin.supported_os.clone(),
270        priority: manifest.plugin.priority,
271        // 第三方插件由宿主强制标注
272        kind: PluginKind::ThirdParty,
273        hotkey: manifest.plugin.hotkey.clone(),
274        icon: manifest
275            .icon
276            .as_ref()
277            .and_then(|icon| read_plugin_icon(plugin_dir, &icon.path)),
278        mode: manifest.plugin.mode,
279    };
280    // 热键仅对 panel 形态生效(前端热键表与 wake_plugin 均按 mode 过滤)
281    if metadata.hotkey.is_some() && metadata.mode != PluginMode::Panel {
282        warn!(
283            plugin_id = %metadata.id,
284            declared = ?metadata.mode,
285            "插件清单声明热键但形态非 panel:热键仅对 panel 形态生效,该热键将不注册"
286        );
287    }
288    metadata
289}
290
291/// 读取清单 [icon] 段声明的图标文件(相对插件目录)并转为 data URL。
292/// 路径逃逸插件目录、文件缺失或超过大小上限返回 None(图标缺失不阻断加载)。
293fn read_plugin_icon(plugin_dir: &Path, icon_path: &str) -> Option<String> {
294    let icon_abs = plugin_dir.join(icon_path);
295    let canonical_icon = icon_abs.canonicalize().ok()?;
296    let canonical_plugin_dir = plugin_dir.canonicalize().ok()?;
297    // 防路径遍历:图标必须位于插件目录内
298    if !canonical_icon.starts_with(&canonical_plugin_dir) {
299        warn!("插件图标路径逃逸插件目录,忽略: {}", icon_path);
300        return None;
301    }
302    // 上限预检:避免把超大文件整个读进内存(字节级上限由 icon::to_data_url 兜底)
303    let meta = std::fs::metadata(&canonical_icon).ok()?;
304    if meta.len() > MAX_ICON_BYTES {
305        warn!("插件图标超过 1MB 上限,忽略: {}", icon_path);
306        return None;
307    }
308    let bytes = std::fs::read(&canonical_icon).ok()?;
309    to_data_url(mime_from_extension(&canonical_icon), &bytes)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// 事件过滤:清单/插件目录变更与 rescan 标记必须重扫(否则快照可能永久陈旧);
317    /// 插件目录深处的普通文件写入不重扫;监听报错也重扫。
318    #[test]
319    fn filters_install_relevant_events() {
320        let root = Path::new(r"C:\data\plugins");
321        let modify = || notify::EventKind::Modify(notify::event::ModifyKind::Any);
322
323        let dir_event =
324            notify::Event::new(modify()).add_path(PathBuf::from(r"C:\data\plugins\com.x"));
325        let manifest_event = notify::Event::new(modify())
326            .add_path(PathBuf::from(r"C:\data\plugins\com.x\manifest.toml"));
327        let deep_event = notify::Event::new(modify())
328            .add_path(PathBuf::from(r"C:\data\plugins\com.x\bin\app.exe"));
329        let outside_event = notify::Event::new(modify()).add_path(PathBuf::from(r"C:\data\other"));
330        let rescan_event =
331            notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan);
332
333        assert!(event_triggers_refresh(Ok(dir_event), root));
334        assert!(event_triggers_refresh(Ok(manifest_event), root));
335        assert!(!event_triggers_refresh(Ok(deep_event), root));
336        assert!(!event_triggers_refresh(Ok(outside_event), root));
337        // 丢事件(缓冲溢出)后必须无条件重扫
338        assert!(event_triggers_refresh(Ok(rescan_event), root));
339        assert!(event_triggers_refresh(
340            Err(notify::Error::generic("buffer overflow")),
341            root
342        ));
343    }
344
345    /// 启动时扫描一次即入内存;未刷新时新增目录不出现在快照里,刷新后立即出现。
346    #[test]
347    fn snapshot_is_scanned_at_start_and_refreshed_explicitly() {
348        let dir = tempfile::tempdir().unwrap();
349        let plugin_dir = dir.path().join("plugins").join("manually-placed-dir");
350        std::fs::create_dir_all(&plugin_dir).unwrap();
351        std::fs::write(plugin_dir.join("manifest.toml"), SAMPLE_MANIFEST).unwrap();
352
353        let cache = InstalledCache::load(dir.path().join("plugins"));
354        assert_eq!(cache.list().len(), 1);
355        assert_eq!(cache.get("com.example.sample").unwrap().dir, plugin_dir);
356        assert!(cache.get("com.example.other").is_none());
357
358        let added = dir.path().join("plugins").join("later");
359        std::fs::create_dir_all(&added).unwrap();
360        std::fs::write(
361            added.join("manifest.toml"),
362            SAMPLE_MANIFEST.replace("com.example.sample", "com.example.later"),
363        )
364        .unwrap();
365        // 读接口不扫盘:未刷新前看不到新目录
366        assert_eq!(cache.list().len(), 1);
367        cache.refresh();
368        assert_eq!(cache.list().len(), 2);
369        assert!(cache.get("com.example.later").is_some());
370    }
371
372    const SAMPLE_MANIFEST: &str = r#"
373[plugin]
374id = "com.example.sample"
375name = "Sample Plugin"
376version = "0.1.0"
377description = "test"
378author = "test"
379mode = "inline"
380triggerKeywords = []
381supportedOs = ["windows"]
382priority = 100
383
384[components]
385provides = ["data_source"]
386"#;
387}