zerolaunch_plugin_host/
installed.rs1use 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
26pub struct InstalledPlugin {
31 pub dir: PathBuf,
33 pub manifest: Manifest,
35 pub metadata: PluginMetadata,
37}
38
39impl InstalledPlugin {
40 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
62pub struct InstalledCache {
68 plugins_dir: PathBuf,
70 snapshot: RwLock<Vec<Arc<InstalledPlugin>>>,
72}
73
74impl InstalledCache {
75 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 fn refresh(&self) {
88 let snapshot = scan_installed(&self.plugins_dir);
89 *self.snapshot.write() = snapshot;
90 }
91
92 fn list(&self) -> Vec<Arc<InstalledPlugin>> {
94 self.snapshot.read().clone()
95 }
96
97 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
109fn 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
123fn 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
135fn 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 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 pub fn installed_snapshot(&self) -> Vec<Arc<InstalledPlugin>> {
165 self.installed_cache.list()
166 }
167
168 pub fn refresh_installed(&self) {
173 self.installed_cache.refresh();
174 }
175
176 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 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 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 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 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 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
254pub(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 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 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
291fn 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 if !canonical_icon.starts_with(&canonical_plugin_dir) {
299 warn!("插件图标路径逃逸插件目录,忽略: {}", icon_path);
300 return None;
301 }
302 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 #[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 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 #[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 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}