use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use notify::{RecursiveMode, Watcher};
use parking_lot::RwLock;
use tracing::{info, warn};
use zerolaunch_plugin_api::plugin::{PluginKind, PluginMetadata, PluginMode};
use zerolaunch_plugin_protocol::Manifest;
use crate::icon::{mime_from_extension, to_data_url, MAX_ICON_BYTES};
use crate::info::PluginInfo;
use crate::manager::{PluginHostManager, PluginRuntimeState};
pub struct InstalledPlugin {
pub dir: PathBuf,
pub manifest: Manifest,
pub metadata: PluginMetadata,
}
impl InstalledPlugin {
pub fn from_dir(dir: &Path) -> Option<Self> {
let manifest_path = dir.join("manifest.toml");
let text = std::fs::read_to_string(&manifest_path).ok()?;
let manifest: Manifest = match toml::from_str(&text) {
Ok(manifest) => manifest,
Err(e) => {
warn!("插件清单解析失败,跳过 {}: {}", manifest_path.display(), e);
return None;
}
};
let metadata = metadata_from_manifest(dir, &manifest);
Some(Self {
dir: dir.to_path_buf(),
manifest,
metadata,
})
}
}
pub struct InstalledCache {
plugins_dir: PathBuf,
snapshot: RwLock<Vec<Arc<InstalledPlugin>>>,
}
impl InstalledCache {
pub(crate) fn load(plugins_dir: PathBuf) -> Self {
let cache = Self {
plugins_dir,
snapshot: RwLock::new(Vec::new()),
};
cache.refresh();
cache
}
fn refresh(&self) {
let snapshot = scan_installed(&self.plugins_dir);
*self.snapshot.write() = snapshot;
}
fn list(&self) -> Vec<Arc<InstalledPlugin>> {
self.snapshot.read().clone()
}
fn get(&self, plugin_id: &str) -> Option<Arc<InstalledPlugin>> {
self.snapshot
.read()
.iter()
.find(|plugin| plugin.metadata.id == plugin_id)
.cloned()
}
}
fn scan_installed(root: &Path) -> Vec<Arc<InstalledPlugin>> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && path.join("manifest.toml").is_file())
.filter_map(|dir| InstalledPlugin::from_dir(&dir).map(Arc::new))
.collect()
}
fn event_triggers_refresh(event: notify::Result<notify::Event>, root: &Path) -> bool {
match event {
Ok(event) => {
event.need_rescan() || event.paths.iter().any(|path| affects_installed(path, root))
}
Err(_) => true,
}
}
fn affects_installed(path: &Path, root: &Path) -> bool {
let Ok(relative) = path.strip_prefix(root) else {
return false;
};
relative.components().count() <= 1
|| path.file_name().is_some_and(|name| name == "manifest.toml")
}
impl PluginHostManager {
pub fn scan_dirs(&self) -> Vec<PathBuf> {
let mut found = Vec::new();
if let Ok(entries) = std::fs::read_dir(self.plugins_dir()) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("manifest.toml").exists() {
found.push(path);
}
}
}
found
}
pub fn installed_snapshot(&self) -> Vec<Arc<InstalledPlugin>> {
self.installed_cache.list()
}
pub fn refresh_installed(&self) {
self.installed_cache.refresh();
}
pub fn installed(&self, plugin_id: &str) -> Option<Arc<InstalledPlugin>> {
if let Some(registration) = self.plugins.get(plugin_id) {
return Some(Arc::new(InstalledPlugin {
dir: registration.plugin_dir.clone(),
manifest: registration.manifest.clone(),
metadata: (*registration.metadata).clone(),
}));
}
self.installed_cache.get(plugin_id)
}
pub fn start_installed_watcher(&self) -> Result<()> {
let mut slot = self.installed_watcher.lock();
if slot.is_some() {
return Ok(());
}
let root = self.plugins_dir.clone();
std::fs::create_dir_all(&root)
.with_context(|| format!("无法创建插件目录: {}", root.display()))?;
let (tx, rx) = std::sync::mpsc::channel::<()>();
let filter_root = root.clone();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if event_triggers_refresh(event, &filter_root) {
let _ = tx.send(());
}
})
.with_context(|| format!("无法监听插件目录: {}", root.display()))?;
watcher
.watch(&root, RecursiveMode::Recursive)
.with_context(|| format!("无法监听插件目录: {}", root.display()))?;
let cache = Arc::clone(&self.installed_cache);
std::thread::Builder::new()
.name("plugin-install-watch".to_string())
.spawn(move || {
while rx.recv().is_ok() {
while rx.recv_timeout(Duration::from_millis(300)).is_ok() {}
cache.refresh();
}
})
.with_context(|| "无法启动插件目录监听线程")?;
*slot = Some(watcher);
info!("插件目录监听已启动: {}", root.display());
Ok(())
}
pub fn to_info(
&self,
installed: &InstalledPlugin,
is_component_enabled: impl Fn(&str) -> bool,
) -> PluginInfo {
self.get_plugin_info(&installed.metadata.id, is_component_enabled)
.unwrap_or_else(|| {
PluginInfo::from_metadata(
&installed.metadata,
PluginRuntimeState::Error,
Vec::new(),
false,
)
})
}
}
pub(crate) fn metadata_from_manifest(plugin_dir: &Path, manifest: &Manifest) -> PluginMetadata {
let metadata = PluginMetadata {
id: manifest.plugin.id.clone(),
name: manifest.plugin.name.clone(),
version: manifest.plugin.version.clone(),
description: manifest.plugin.description.clone(),
author: manifest.plugin.author.clone(),
trigger_keywords: manifest.plugin.trigger_keywords.clone(),
trigger_description: manifest.plugin.trigger_description.clone(),
supported_os: manifest.plugin.supported_os.clone(),
priority: manifest.plugin.priority,
kind: PluginKind::ThirdParty,
hotkey: manifest.plugin.hotkey.clone(),
icon: manifest
.icon
.as_ref()
.and_then(|icon| read_plugin_icon(plugin_dir, &icon.path)),
mode: manifest.plugin.mode,
};
if metadata.hotkey.is_some() && metadata.mode != PluginMode::Panel {
warn!(
plugin_id = %metadata.id,
declared = ?metadata.mode,
"插件清单声明热键但形态非 panel:热键仅对 panel 形态生效,该热键将不注册"
);
}
metadata
}
fn read_plugin_icon(plugin_dir: &Path, icon_path: &str) -> Option<String> {
let icon_abs = plugin_dir.join(icon_path);
let canonical_icon = icon_abs.canonicalize().ok()?;
let canonical_plugin_dir = plugin_dir.canonicalize().ok()?;
if !canonical_icon.starts_with(&canonical_plugin_dir) {
warn!("插件图标路径逃逸插件目录,忽略: {}", icon_path);
return None;
}
let meta = std::fs::metadata(&canonical_icon).ok()?;
if meta.len() > MAX_ICON_BYTES {
warn!("插件图标超过 1MB 上限,忽略: {}", icon_path);
return None;
}
let bytes = std::fs::read(&canonical_icon).ok()?;
to_data_url(mime_from_extension(&canonical_icon), &bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_install_relevant_events() {
let root = Path::new(r"C:\data\plugins");
let modify = || notify::EventKind::Modify(notify::event::ModifyKind::Any);
let dir_event =
notify::Event::new(modify()).add_path(PathBuf::from(r"C:\data\plugins\com.x"));
let manifest_event = notify::Event::new(modify())
.add_path(PathBuf::from(r"C:\data\plugins\com.x\manifest.toml"));
let deep_event = notify::Event::new(modify())
.add_path(PathBuf::from(r"C:\data\plugins\com.x\bin\app.exe"));
let outside_event = notify::Event::new(modify()).add_path(PathBuf::from(r"C:\data\other"));
let rescan_event =
notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan);
assert!(event_triggers_refresh(Ok(dir_event), root));
assert!(event_triggers_refresh(Ok(manifest_event), root));
assert!(!event_triggers_refresh(Ok(deep_event), root));
assert!(!event_triggers_refresh(Ok(outside_event), root));
assert!(event_triggers_refresh(Ok(rescan_event), root));
assert!(event_triggers_refresh(
Err(notify::Error::generic("buffer overflow")),
root
));
}
#[test]
fn snapshot_is_scanned_at_start_and_refreshed_explicitly() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("manually-placed-dir");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("manifest.toml"), SAMPLE_MANIFEST).unwrap();
let cache = InstalledCache::load(dir.path().join("plugins"));
assert_eq!(cache.list().len(), 1);
assert_eq!(cache.get("com.example.sample").unwrap().dir, plugin_dir);
assert!(cache.get("com.example.other").is_none());
let added = dir.path().join("plugins").join("later");
std::fs::create_dir_all(&added).unwrap();
std::fs::write(
added.join("manifest.toml"),
SAMPLE_MANIFEST.replace("com.example.sample", "com.example.later"),
)
.unwrap();
assert_eq!(cache.list().len(), 1);
cache.refresh();
assert_eq!(cache.list().len(), 2);
assert!(cache.get("com.example.later").is_some());
}
const SAMPLE_MANIFEST: &str = r#"
[plugin]
id = "com.example.sample"
name = "Sample Plugin"
version = "0.1.0"
description = "test"
author = "test"
mode = "inline"
triggerKeywords = []
supportedOs = ["windows"]
priority = 100
[components]
provides = ["data_source"]
"#;
}