Files
cursor-byok/server/src/plugin/catalog.rs
T
leookun a5bbe67845 refactor: replace Button with TruncatedButton in CursorModelCards and PluginManagementPage
- Updated the UI components in CursorModelCards and PluginManagementPage to use TruncatedButton for better text handling and display.
- Adjusted styles in CursorSettings and PluginManagementPage to ensure proper button layout and responsiveness.
- Added new ActionMenu component for handling additional actions in PluginManagementPage.
- Enhanced localization files to include new strings for the ActionMenu and TruncatedButton components.
2026-08-30 20:45:27 +08:00

318 lines
11 KiB
Rust

//! Discovers plugin manifests and evaluates serializable TypeScript definitions.
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use super::{
definition::PluginDefinitionLoader,
descriptor::PluginModuleDefinition,
manifest::{validate_id, PluginManifest},
};
use crate::{config, Error, Result};
const MANIFEST_FILE_NAME: &str = "plugin.json";
const MAX_ICON_BYTES: u64 = 1024 * 1024;
#[derive(Clone)]
pub struct PluginCatalog {
roots: Vec<PathBuf>,
definition_loader: PluginDefinitionLoader,
app_version: String,
}
#[derive(Clone)]
pub(crate) struct PluginEntry {
pub directory: PathBuf,
pub entry: PathBuf,
pub manifest: PluginManifest,
pub definition: PluginModuleDefinition,
pub icon: String,
}
impl PluginCatalog {
pub fn managed(app_version: String) -> Result<Self> {
let installed = config::managed_data_dir()?.join("plugins/installed");
fs::create_dir_all(&installed)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&installed, fs::Permissions::from_mode(0o700))?;
}
// 内置插件按版本预装进 installed;版本一致时不写盘。
super::builtin::install(&installed)?;
// 扫描顺序即优先级:debug 下源码目录优先,保证内置插件热改生效;
// 发布构建只有 installed 一个根。
#[cfg(debug_assertions)]
let roots = vec![
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in"),
installed,
];
#[cfg(not(debug_assertions))]
let roots = vec![installed];
Ok(Self {
roots,
definition_loader: PluginDefinitionLoader::managed()?,
app_version,
})
}
pub(crate) fn loader(&self) -> &PluginDefinitionLoader {
&self.definition_loader
}
pub(crate) async fn entries(&self, executable: &Path) -> Vec<PluginEntry> {
let mut plugins = BTreeMap::new();
for root in &self.roots {
let mut directories = match child_directories(root) {
Ok(value) => value,
Err(error) => {
tracing::warn!(path = %root.display(), %error, "failed to scan plugin directory");
continue;
}
};
directories.sort();
for directory in directories {
match load_plugin(
&directory,
&self.definition_loader,
executable,
&self.app_version,
)
.await
{
Ok(entry) => {
if plugins.contains_key(&entry.manifest.id) {
tracing::warn!(plugin = %entry.manifest.id, path = %directory.display(), "ignoring duplicate plugin");
} else {
plugins.insert(entry.manifest.id.clone(), entry);
}
}
Err(error) => {
tracing::warn!(path = %directory.display(), %error, "ignoring invalid plugin")
}
}
}
}
plugins.into_values().collect()
}
pub(crate) fn manifests(&self) -> Vec<(PluginManifest, String)> {
let mut plugins = BTreeMap::new();
for root in &self.roots {
let Ok(mut directories) = child_directories(root) else {
continue;
};
directories.sort();
for directory in directories {
let loaded = (|| -> Result<_> {
let manifest: PluginManifest =
serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?;
manifest.validate(&directory)?;
require_app_version(&manifest, &self.app_version)?;
let icon = icon_data_url(&directory, &manifest.icon)?;
Ok((manifest, icon))
})();
if let Ok((manifest, icon)) = loaded {
plugins
.entry(manifest.id.clone())
.or_insert((manifest, icon));
}
}
}
plugins.into_values().collect()
}
}
fn child_directories(root: &Path) -> Result<Vec<PathBuf>> {
if !root.exists() {
return Ok(Vec::new());
}
let mut directories = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if entry.file_type()?.is_dir() && !entry.file_name().to_string_lossy().starts_with('.') {
directories.push(entry.path());
}
}
Ok(directories)
}
/// 应用过旧时拒绝加载,让插件的 minAppVersion 声明生效。
fn require_app_version(manifest: &PluginManifest, app_version: &str) -> Result<()> {
let Some(minimum) = &manifest.min_app_version else {
return Ok(());
};
if super::manifest::version_at_least(app_version, minimum) {
return Ok(());
}
Err(Error::Config(format!(
"plugin '{}' requires app version {minimum} or newer (current {app_version})",
manifest.id
)))
}
async fn load_plugin(
directory: &Path,
loader: &PluginDefinitionLoader,
executable: &Path,
app_version: &str,
) -> Result<PluginEntry> {
let manifest: PluginManifest =
serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?;
manifest.validate(directory)?;
require_app_version(&manifest, app_version)?;
let icon = icon_data_url(directory, &manifest.icon)?;
let entry = directory.join(&manifest.entry).canonicalize()?;
let definition = loader.load(executable, directory, &entry).await?;
validate_definition(&manifest.id, &definition)?;
Ok(PluginEntry {
directory: directory.to_path_buf(),
entry,
manifest,
definition,
icon,
})
}
/// 显示文本必须是非空字符串,或全为非空字符串的 locale 映射。
fn validate_localized_text(value: &serde_json::Value, label: &str) -> Result<()> {
match value {
serde_json::Value::String(text) if !text.trim().is_empty() => Ok(()),
serde_json::Value::Object(map)
if !map.is_empty()
&& map
.values()
.all(|entry| entry.as_str().is_some_and(|text| !text.trim().is_empty())) =>
{
Ok(())
}
_ => Err(Error::Config(format!(
"{label} must be a non-empty string or a locale map of non-empty strings"
))),
}
}
fn validate_definition(plugin_id: &str, definition: &PluginModuleDefinition) -> Result<()> {
if definition.providers.is_empty() {
return Err(Error::Config(format!(
"plugin '{plugin_id}' must define at least one provider"
)));
}
let mut provider_ids = std::collections::HashSet::new();
for provider in &definition.providers {
validate_id(&provider.id, "plugin provider id")?;
validate_localized_text(
&provider.display_name,
&format!(
"plugin '{plugin_id}' provider '{}' displayName",
provider.id
),
)?;
if provider.provider_type.trim().is_empty() {
return Err(Error::Config(format!(
"plugin '{plugin_id}' provider '{}' requires providerType",
provider.id
)));
}
if !provider_ids.insert(provider.id.clone()) {
return Err(Error::Config(format!(
"plugin '{plugin_id}' contains duplicate provider '{}'",
provider.id
)));
}
if let Some(resource_type) = &provider.resource_type {
if !definition
.resources
.iter()
.any(|resource| &resource.resource_type == resource_type)
{
return Err(Error::Config(format!(
"plugin '{plugin_id}' provider '{}' consumes undeclared resource '{resource_type}'",
provider.id
)));
}
}
}
let mut resource_types = std::collections::HashSet::new();
for resource in &definition.resources {
validate_id(&resource.resource_type, "plugin resource type")?;
validate_localized_text(
&resource.display_name,
&format!(
"plugin '{plugin_id}' resource '{}' displayName",
resource.resource_type
),
)?;
if !resource_types.insert(resource.resource_type.clone()) {
return Err(Error::Config(format!(
"plugin '{plugin_id}' contains duplicate resource type '{}'",
resource.resource_type
)));
}
for method in &resource.add {
validate_id(&method.id, "plugin add method id")?;
if method.method_type != super::descriptor::OAUTH2_ADD_METHOD {
return Err(Error::Config(format!(
"plugin '{plugin_id}' add method '{}' uses unsupported type '{}'",
method.id, method.method_type
)));
}
}
}
Ok(())
}
fn icon_data_url(directory: &Path, relative: &str) -> Result<String> {
let root = directory.canonicalize()?;
let path = directory.join(relative).canonicalize()?;
if !path.starts_with(&root) {
return Err(Error::Config(format!(
"plugin icon escapes its directory: {relative}"
)));
}
if fs::metadata(&path)?.len() > MAX_ICON_BYTES {
return Err(Error::Config(format!(
"plugin icon exceeds {MAX_ICON_BYTES} bytes: {relative}"
)));
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let mime = match extension.as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
_ => {
return Err(Error::Config(format!(
"unsupported plugin icon: {relative}"
)))
}
};
Ok(format!(
"data:{mime};base64,{}",
STANDARD.encode(fs::read(path)?)
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repository_examples_have_valid_static_manifests() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in");
let sdk = tempfile::tempdir().unwrap();
let catalog = PluginCatalog {
roots: vec![root],
definition_loader: PluginDefinitionLoader::for_test(sdk.path()).unwrap(),
app_version: env!("CARGO_PKG_VERSION").into(),
};
assert!(!catalog.manifests().is_empty());
}
}