//! Generic AGC plugin host. //! //! This module deliberately contains no target-editor knowledge. Plugin //! discovery, manifest validation, process lifecycle, JSON-RPC, UI/capability //! registration, permission checks and audit records are shared by every //! editor. Target-specific work is delegated to `editor_adapter`. use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::Arc; use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::{Manager, State}; use crate::editor_adapter::{EditorAdapter, EditorConnectionInfo}; type EditorRegistry = Arc>>>; type ProjectContext = Arc>>; type PendingRpc = Arc>>>>; const PLUGIN_MANIFEST_FILE_NAME: &str = "plugin.json"; const PLUGIN_MANIFEST_FALLBACK: &str = ".codex-plugin/plugin.json"; const AGENT_PLUGINS_SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; const AGC_EXTENSION_NAMESPACE: &str = "world.genarrative.agc"; const PLUGIN_PROTOCOL_VERSION: &str = "agc.plugin.v1"; const PLUGIN_API_VERSION: &str = "v1"; const AUDIT_FILE_NAME: &str = "audit.jsonl"; const RPC_TIMEOUT: Duration = Duration::from_secs(10); // 编辑器连接可包含受控引导;外层必须覆盖引导和编辑器命令的完整期限。 const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", "project.read", "editor.rpc", "ui.register", "capability.register", ]; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelManifest { pub(crate) id: String, pub(crate) title: String, pub(crate) entry: String, #[serde(default)] pub(crate) placement: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginManifest { pub(crate) id: String, pub(crate) name: String, pub(crate) version: String, #[serde(default = "default_api_version")] pub(crate) api_version: String, pub(crate) entry: Option, #[serde(default)] pub(crate) permissions: BTreeSet, #[serde(default = "default_enabled")] pub(crate) enabled: bool, #[serde(default)] pub(crate) adapter: Option, #[serde(default)] pub(crate) panels: Vec, } fn default_api_version() -> String { PLUGIN_API_VERSION.to_string() } fn default_enabled() -> bool { true } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginCommandDescriptor { pub(crate) id: String, pub(crate) title: String, pub(crate) description: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelDescriptor { pub(crate) id: String, pub(crate) title: String, pub(crate) entry: String, pub(crate) placement: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginCapabilityDescriptor { pub(crate) id: String, pub(crate) description: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelContent { panel: PluginPanelDescriptor, html: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginSummary { pub(crate) id: String, pub(crate) name: String, pub(crate) version: String, pub(crate) api_version: String, pub(crate) enabled: bool, /// 内置插件随包分发,不能卸载,只能通过可用开关启停。 pub(crate) builtin: bool, pub(crate) has_runtime: bool, pub(crate) status: String, pub(crate) adapter: Option, pub(crate) permissions: Vec, pub(crate) commands: Vec, pub(crate) panels: Vec, pub(crate) capabilities: Vec, pub(crate) last_error: Option, } /// Unified catalog entry. Skills and MCPs keep their existing runtime /// adapters, while executable plugins use this host's lifecycle and RPC. #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgcExtensionSummary { pub(crate) kind: String, pub(crate) id: String, pub(crate) name: String, pub(crate) enabled: bool, pub(crate) builtin: bool, pub(crate) status: String, pub(crate) plugin: Option, pub(crate) client_extension: Option, } struct RunningPlugin { child: Child, #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, lines: Option>, pending: PendingRpc, registrations: Arc>, next_request_id: u64, editor_context: Option, active: Arc, } impl Drop for RunningPlugin { fn drop(&mut self) { self.active.store(false, Ordering::SeqCst); if let Some(context) = &self.editor_context { if let Ok(mut project) = context.try_lock() { *project = None; } } #[cfg(unix)] unsafe { libc::kill(-(self.child.id() as i32), libc::SIGKILL); } let _ = self.child.kill(); let _ = self.child.wait(); } } #[derive(Default)] struct PluginRegistrations { commands: BTreeMap, panels: BTreeMap, capabilities: BTreeMap, subscriptions: BTreeMap, } #[derive(Default)] struct PluginRegistrationsSnapshot { commands: Vec, panels: Vec, capabilities: Vec, } fn register_entry( entries: &mut BTreeMap, id: String, entry: T, ) -> Result<(), String> { if entries.len() >= 128 || entries.contains_key(&id) { return Err("插件注册项重复或超过数量限制".to_string()); } entries.insert(id, entry); Ok(()) } struct PluginRecord { id: String, manifest: PluginManifest, root: PathBuf, status: String, last_error: Option, running: Option, } #[derive(Default)] struct PluginHostState { root: Option, workspace: Option, plugins: BTreeMap, active_project: ProjectContext, editors: EditorRegistry, } #[derive(Default)] pub(crate) struct PluginHost { state: Mutex, } #[derive(Debug, Deserialize)] struct RpcEnvelope { #[serde(default)] jsonrpc: Option, #[serde(default)] id: Option, #[serde(default)] method: Option, #[serde(default)] params: Option, #[serde(default, deserialize_with = "deserialize_rpc_result")] result: Option, #[serde(default)] error: Option, } fn deserialize_rpc_result<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { Value::deserialize(deserializer).map(Some) } fn valid_identifier(value: &str) -> bool { let mut chars = value.chars(); matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit()) && value.len() <= 64 && value .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')) } fn valid_text(value: &str, max: usize) -> bool { let trimmed = value.trim(); !trimmed.is_empty() && trimmed.len() <= max && !trimmed.chars().any(char::is_control) } fn validate_relative_path(value: &str) -> Result<(), String> { let path = Path::new(value.strip_prefix("./").unwrap_or(value)); if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) || value.contains(['\\', ':']) || path.is_absolute() { return Err("插件入口必须是相对路径".to_string()); } if path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err("插件入口不能包含越界或特殊路径".to_string()); } Ok(()) } fn project_read_path(project: &Path, relative: &str) -> Result { validate_relative_path(relative)?; let canonical_project = project .canonicalize() .map_err(|_| "当前项目不可读".to_string())?; let mut candidate = canonical_project.clone(); for component in Path::new(relative).components() { let Component::Normal(name) = component else { return Err("项目文件路径无效".to_string()); }; let name_text = name.to_string_lossy().to_ascii_lowercase(); if matches!( name_text.as_str(), ".agent" | ".agents" | ".codex" | ".git" | ".env" ) || name_text.starts_with(".env.") { return Err("插件不能读取项目控制或凭据目录".to_string()); } candidate.push(name); let metadata = fs::symlink_metadata(&candidate).map_err(|_| "项目文件不可读".to_string())?; if metadata.file_type().is_symlink() { return Err("插件不能读取符号链接".to_string()); } #[cfg(windows)] { use std::os::windows::fs::MetadataExt; if metadata.file_attributes() & 0x400 != 0 { return Err("插件不能读取重解析点".to_string()); } } } let canonical = candidate .canonicalize() .map_err(|_| "项目文件不可读".to_string())?; if !canonical.starts_with(&canonical_project) { return Err("插件文件读取越出项目目录".to_string()); } Ok(canonical) } pub(crate) fn validate_manifest(manifest: &PluginManifest) -> Result<(), String> { if !valid_identifier(&manifest.id) { return Err("插件 id 必须使用小写字母、数字、点、短横线或下划线".to_string()); } if !valid_text(&manifest.name, 80) || !valid_text(&manifest.version, 32) { return Err("插件名称或版本无效".to_string()); } if manifest.api_version != PLUGIN_API_VERSION { return Err(format!("不支持的插件 API 版本:{}", manifest.api_version)); } if let Some(entry) = manifest.entry.as_deref() { validate_relative_path(entry)?; } if manifest .permissions .iter() .any(|permission| !KNOWN_PERMISSIONS.contains(&permission.as_str())) { return Err("插件声明了未知权限".to_string()); } if let Some(adapter) = &manifest.adapter { if !valid_identifier(adapter) { return Err("编辑器适配器标识无效".to_string()); } } let mut panel_ids = BTreeSet::new(); for panel in &manifest.panels { if !valid_identifier(&panel.id) || !valid_text(&panel.title, 80) || !panel_ids.insert(panel.id.clone()) { return Err("插件面板声明无效或存在重复 id".to_string()); } validate_relative_path(&panel.entry)?; } Ok(()) } fn manifest_path(root: &Path) -> Option { for candidate in [PLUGIN_MANIFEST_FILE_NAME, PLUGIN_MANIFEST_FALLBACK] { let path = root.join(candidate); if path.is_file() { return Some(path); } } None } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifest { #[serde(rename = "$schema")] schema: Option, name: String, #[serde(default)] version: Option, #[serde(default)] extensions: BTreeMap, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AgcRuntimeExtension { #[serde(default)] api_version: Option, #[serde(default)] entry: Option, #[serde(default)] permissions: BTreeSet, #[serde(default = "default_enabled")] enabled: bool, #[serde(default)] adapter: Option, #[serde(default)] panels: Vec, } pub(crate) fn read_plugin_manifest(root: &Path) -> Result { let path = manifest_path(root).ok_or_else(|| "缺少 plugin.json manifest".to_string())?; let metadata = fs::symlink_metadata(&path).map_err(|error| format!("读取插件 manifest 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_MANIFEST_BYTES { return Err("插件 manifest 必须是受限的普通文件".to_string()); } let bytes = fs::read(&path).map_err(|error| format!("读取插件 manifest 失败:{error}"))?; let raw = serde_json::from_slice::(&bytes) .map_err(|error| format!("解析插件 manifest 失败:{error}"))?; if path == root.join(PLUGIN_MANIFEST_FILE_NAME) && raw.schema.as_deref() != Some(AGENT_PLUGINS_SCHEMA) { return Err("不支持的 Agent Plugins schema".to_string()); } if path == root.join(PLUGIN_MANIFEST_FILE_NAME) && raw.name.split('-').any(|part| { part.is_empty() || !part .chars() .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) }) { return Err("Agent Plugin name 必须使用 kebab-case".to_string()); } let runtime = serde_json::from_value::( raw.extensions .get(AGC_EXTENSION_NAMESPACE) .cloned() .unwrap_or_else(|| json!({})), ) .map_err(|error| format!("AGC 插件运行扩展无效:{error}"))?; let display_name = raw .extensions .get("com.openai") .and_then(|extension| extension.get("interface")) .and_then(|interface| interface.get("displayName")) .and_then(Value::as_str) .unwrap_or(&raw.name) .to_string(); let manifest = PluginManifest { id: raw.name, name: display_name, version: raw.version.unwrap_or_else(|| "0.0.0".to_string()), api_version: runtime.api_version.unwrap_or_else(default_api_version), entry: runtime.entry, permissions: runtime.permissions, enabled: runtime.enabled, adapter: runtime.adapter, panels: runtime.panels, }; validate_manifest(&manifest)?; if let Some(entry) = manifest.entry.as_deref() { let entry = project_read_path(root, entry.strip_prefix("./").unwrap_or(entry))?; let metadata = fs::symlink_metadata(&entry).map_err(|error| format!("插件入口不可读:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("插件入口必须是普通文件".to_string()); } } Ok(manifest) } fn plugin_root(config_dir: &Path) -> Result { let root = config_dir.join("extensions"); fs::create_dir_all(&root).map_err(|error| format!("准备插件目录失败:{error}"))?; Ok(root) } /// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, /// 开发构建再回退仓库里的 `plugins/` 工作区。 pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { let workspace = PathBuf::from(workspace); if workspace.is_dir() { return Some(workspace); } } if let Ok(resource_dir) = app.path().resource_dir() { let bundled = resource_dir.join("plugins"); if bundled.is_dir() { return Some(bundled); } } #[cfg(debug_assertions)] { let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); let workspace = repo_root.join("plugins"); if workspace.is_dir() { return Some(workspace); } } None } /// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。 #[derive(Clone, Debug)] struct ScannedPluginSource { id: String, name: String, original_name: String, /// `Some` 表示启用状态由来源索引决定;`None` 表示沿用插件 manifest 声明。 enabled: Option, root: PathBuf, } impl ScannedPluginSource { fn from_imported(source: crate::client_extensions::ClientPluginSource) -> Self { Self { id: source.item.id, name: source.item.name, original_name: source.item.original_name, enabled: Some(source.item.enabled), root: source.root, } } } /// 扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的子目录是一个插件包。 /// /// 工作区插件随包分发:内置插件按用户可用开关决定启用状态,其它工作区插件 /// 沿用 manifest 声明。 fn workspace_plugin_sources(root: &Path) -> Result, String> { let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(error) => return Err(format!("读取插件工作区失败:{error}")), }; let mut sources = Vec::new(); for entry in entries.flatten() { let plugin_root = entry.path(); if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { continue; } let id = entry.file_name().to_string_lossy().into_owned(); let enabled = crate::builtin_plugins::toggle_state(&id); sources.push(ScannedPluginSource { id: id.clone(), name: id.clone(), original_name: id, enabled, root: plugin_root, }); } sources.sort_by(|left, right| left.id.cmp(&right.id)); Ok(sources) } fn audit_path(root: &Path) -> PathBuf { root.join(AUDIT_FILE_NAME) } fn audit(root: &Path, plugin_id: &str, action: &str, allowed: bool, reason: Option<&str>) { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_millis()) .unwrap_or_default(); let record = json!({ "schemaVersion": PLUGIN_PROTOCOL_VERSION, "timestamp": timestamp, "pluginId": plugin_id, "action": action, "allowed": allowed, "reason": reason, }); let _ = crate::append_bounded_diagnostic_line(&audit_path(root), &record.to_string()); } fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { let entry = root.join( manifest .entry .as_deref() .ok_or_else(|| "该 Plugin 只包含 Skill/MCP,不能作为进程启动".to_string())?, ); let mut command = if matches!( entry.extension().and_then(|value| value.to_str()), Some("js" | "mjs" | "cjs") ) { let mut command = Command::new("node"); command.arg(&entry); command } else { Command::new(&entry) }; command .env_clear() .current_dir(root) .env("AGC_PLUGIN_ID", &manifest.id) .env("AGC_PLUGIN_PROTOCOL", PLUGIN_PROTOCOL_VERSION) .env("AGC_PLUGIN_API_VERSION", &manifest.api_version) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); for variable in [ "PATH", "SystemRoot", "WINDIR", "SystemDrive", "ComSpec", "TEMP", "TMP", "PATHEXT", ] { if let Some(value) = std::env::var_os(variable) { command.env(variable, value); } } #[cfg(windows)] { use std::os::windows::process::CommandExt; command.creation_flags(0x0800_0000); } #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } let mut child = command .spawn() .map_err(|error| format!("启动插件失败:{error}"))?; #[cfg(windows)] let job = match crate::process_session::WindowsProcessJob::assign_std(&child) { Ok(job) => job, Err(error) => { let _ = child.kill(); let _ = child.wait(); return Err(error); } }; let stdin = Arc::new(Mutex::new( child .stdin .take() .ok_or_else(|| "插件 stdin 不可用".to_string())?, )); let stdout = child .stdout .take() .ok_or_else(|| "插件 stdout 不可用".to_string())?; let (sender, receiver) = mpsc::sync_channel(16); thread::spawn(move || { let mut reader = BufReader::new(stdout); while let Ok(Some(line)) = read_bounded_rpc_line(&mut reader) { if sender.send(line).is_err() { break; } } }); Ok(RunningPlugin { child, #[cfg(windows)] _job: job, stdin, lines: Some(receiver), pending: Arc::new(Mutex::new(BTreeMap::new())), registrations: Arc::new(Mutex::new(PluginRegistrations::default())), next_request_id: 1, editor_context: None, active: Arc::new(AtomicBool::new(true)), }) } fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, String> { let mut bytes = Vec::new(); loop { let buffer = reader .fill_buf() .map_err(|_| "读取插件输出失败".to_string())?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) } else { Err("插件输出缺少换行".to_string()) }; } let newline = buffer.iter().position(|byte| *byte == b'\n'); let count = newline.map_or(buffer.len(), |index| index + 1); if bytes.len() + count > MAX_RPC_BYTES { return Err("插件输出超过大小限制".to_string()); } bytes.extend_from_slice(&buffer[..count]); reader.consume(count); if newline.is_some() { return String::from_utf8(bytes) .map(Some) .map_err(|_| "插件输出不是 UTF-8".to_string()); } } } fn write_rpc(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> { let payload = serialize_rpc(value)?; stdin .write_all(&payload) .map_err(|error| format!("写入插件 RPC 失败:{error}"))?; stdin .flush() .map_err(|error| format!("刷新插件 RPC 失败:{error}")) } fn serialize_rpc(value: &Value) -> Result, String> { let mut payload = serde_json::to_vec(value).map_err(|error| format!("序列化插件 RPC 失败:{error}"))?; if payload.len() > MAX_RPC_BYTES { return Err("插件 RPC 请求过大".to_string()); } payload.push(b'\n'); Ok(payload) } fn write_prepared_rpc( writer: &mut impl Write, payload: &[u8], phase: &AtomicU8, ) -> Result<(), String> { // 0=未写,1=可能已写,2=截止前取消;取消后后台线程不得补发。 phase .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) .map_err(|_| "插件 RPC 已在写入前取消".to_string())?; writer .write_all(payload) .and_then(|_| writer.flush()) .map_err(|error| format!("写入插件 RPC 失败:{error}")) } fn cancel_rpc_before_write(phase: &AtomicU8) -> bool { phase .compare_exchange(0, 2, Ordering::SeqCst, Ordering::SeqCst) .is_ok() } fn finalize_managed_plugin_result( phase: &AtomicU8, result: Result, mark_uncertain: impl FnOnce() -> Result<(), String>, ) -> Result { if phase.load(Ordering::SeqCst) == 1 && !result.as_ref().is_ok_and(|value| { crate::editor_adapters::editor_execute_receipt_is_valid(value) && value["status"] != "needs-reconciliation" }) { let message = if mark_uncertain().is_ok() { "插件执行回执丢失或无效,请人工核对,禁止重放" } else { "插件执行结果待核对,持久阻断记录未能确认,请停止执行并人工核对" }; Ok(crate::editor_adapters::editor_reconciliation(message)) } else { result } } fn write_rpc_shared(stdin: &Arc>, value: &Value) -> Result<(), String> { let mut stdin = stdin .lock() .map_err(|_| "插件 stdin 锁已损坏".to_string())?; write_rpc(&mut stdin, value) } fn descriptor_from_params Deserialize<'de>>( params: Option, ) -> Result { serde_json::from_value(params.unwrap_or_else(|| json!({}))) .map_err(|error| format!("插件注册参数无效:{error}")) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterCommandInput { id: String, title: String, #[serde(default)] description: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterPanelInput { id: String, title: String, entry: String, #[serde(default)] placement: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterCapabilityInput { id: String, #[serde(default)] description: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProjectReadInput { path: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EditorRpcInput { #[serde(default)] adapter: Option, method: String, #[serde(default)] params: Value, } fn permission_for_method(method: &str) -> Option<&'static str> { match method { "host.project.read" => Some("project.read"), "host.rpc" => Some("editor.rpc"), "host.events.subscribe" | "host.events.unsubscribe" => Some("events.subscribe"), "host.registerPanel" | "host.unregisterPanel" => Some("ui.register"), "host.registerCapability" | "host.unregisterCapability" => Some("capability.register"), "host.registerCommand" | "host.unregisterCommand" => Some("ui.register"), _ => None, } } fn has_editor_adapter(editors: &EditorRegistry, adapter: &str) -> Result { Ok(editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())? .contains_key(adapter)) } fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { let adapter = match id { crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => "cocos-editor", crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => "unity-editor", crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => "godot-editor", _ => return Ok(()), }; if !has_editor_adapter(editors, adapter)? { let name = match adapter { "cocos-editor" => "Cocos", "unity-editor" => "Unity", _ => "Godot", }; return Err(format!("当前客户端不支持 {name} 编辑器桥接")); } Ok(()) } fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool { match id { crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => project.is_some_and(|path| { crate::project::discover_local_godot_project_root(path) .ok() .flatten() .is_some() }), _ => true, } } fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> { if !plugin_matches_project( id, project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref(), ) { return Err("编辑器插件与当前项目类型不匹配".to_string()); } Ok(()) } fn controlled_editor_params(project: &Path, mut params: Value) -> Result { if params.is_null() { params = json!({}); } let params = params .as_object_mut() .ok_or_else(|| "编辑器参数必须是对象".to_string())?; let canonical = project .canonicalize() .map_err(|_| "当前项目目录不可读".to_string())?; if let Some(explicit) = params.get("projectPath") { let explicit = explicit .as_str() .ok_or_else(|| "projectPath 必须是字符串".to_string())?; let explicit = Path::new(explicit) .canonicalize() .map_err(|_| "编辑器项目目录不可读".to_string())?; if explicit != canonical { return Err("编辑器 projectPath 必须匹配当前受控项目".to_string()); } } params.insert( "projectPath".to_string(), json!(canonical.to_string_lossy()), ); Ok(Value::Object(params.clone())) } impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; state.root = Some(root.clone()); if state.workspace.is_none() { if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { let workspace = PathBuf::from(workspace); if workspace.is_dir() { state.workspace = Some(workspace); } } } self.scan_locked(&mut state, &root) } /// 注册 `plugins/` 工作区目录,让随包插件无需 AppData 导入即可被发现。 pub(crate) fn set_plugin_workspace(&self, workspace: PathBuf) -> Result<(), String> { if !workspace.is_dir() { return Err("插件工作区必须是目录".to_string()); } let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; state.workspace = Some(workspace); let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root) } fn scan_locked(&self, state: &mut PluginHostState, root: &Path) -> Result<(), String> { let workspace_sources = match state.workspace.clone() { Some(workspace) => workspace_plugin_sources(&workspace)?, None => Vec::new(), }; // 内置插件优先:随包插件不能被 AppData 同名导入覆盖,也不能被卸载。 let mut sources = workspace_sources .iter() .filter(|source| crate::builtin_plugins::is_builtin(&source.id)) .cloned() .collect::>(); for source in crate::client_extensions::client_plugin_sources_at(root)? .into_iter() .map(ScannedPluginSource::from_imported) { if !sources.iter().any(|existing| existing.id == source.id) { sources.push(source); } } for source in workspace_sources { if !sources.iter().any(|existing| existing.id == source.id) { sources.push(source); } } let mut discovered = BTreeMap::new(); for source in sources { let id = source.id.clone(); match read_plugin_manifest(&source.root) { Ok(mut manifest) => { if let Some(enabled) = source.enabled { manifest.enabled = enabled; } if source.name != source.original_name { manifest.name = source.name.clone(); } let existing = state .plugins .remove(&id) .filter(|existing| existing.manifest == manifest && manifest.enabled); let previous_error = existing .as_ref() .and_then(|existing| existing.last_error.clone()); let previously_failed = existing .as_ref() .is_some_and(|existing| existing.status == "failed"); let mut running = existing.and_then(|existing| existing.running); let exited = running .as_mut() .and_then(|running| running.child.try_wait().ok().flatten()) .is_some(); if exited { running = None; } let status = if !manifest.enabled { "disabled" } else if exited || previously_failed { "failed" } else if running.is_some() { "running" } else if manifest.entry.is_none() { "package" } else { "stopped" } .to_string(); discovered.insert( id.clone(), PluginRecord { id, manifest, root: source.root, status, last_error: if exited { Some("插件进程已退出".to_string()) } else { previous_error }, running, }, ); } Err(error) => { discovered.insert( id.clone(), PluginRecord { id: id.clone(), manifest: PluginManifest { id, name: source.name, version: "0".to_string(), api_version: PLUGIN_API_VERSION.to_string(), entry: None, permissions: BTreeSet::new(), enabled: false, adapter: None, panels: Vec::new(), }, root: source.root, status: "invalid".to_string(), last_error: Some(error), running: None, }, ); } } } state.plugins = discovered; Ok(()) } fn registrations(record: &PluginRecord) -> PluginRegistrationsSnapshot { let Some(running) = record.running.as_ref() else { return PluginRegistrationsSnapshot::default(); }; let Ok(registrations) = running.registrations.lock() else { return PluginRegistrationsSnapshot::default(); }; PluginRegistrationsSnapshot { commands: registrations.commands.values().cloned().collect(), panels: registrations.panels.values().cloned().collect(), capabilities: registrations.capabilities.values().cloned().collect(), } } pub(crate) fn list(&self) -> Result, String> { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; let project = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); state .plugins .values() .filter(|record| { plugin_matches_project(&record.id, project.as_deref()) && require_plugin_adapter(&record.id, &state.editors).is_ok() }) .map(|record| self.summary_locked(record)) .collect() } pub(crate) fn refresh(&self) -> Result, String> { self.list() } pub(crate) fn list_extensions(&self) -> Result, String> { let plugins = self.list()?; let mut entries = plugins .into_iter() .map(|plugin| AgcExtensionSummary { kind: "plugin".to_string(), id: plugin.id.clone(), name: plugin.name.clone(), enabled: plugin.enabled, builtin: plugin.builtin, status: plugin.status.clone(), plugin: Some(plugin), client_extension: None, }) .collect::>(); let root = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())? .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; for extension in crate::client_extensions::list_client_extensions_at(&root)? { if extension.extension_type == "plugin" { if let Some(entry) = entries.iter_mut().find(|entry| entry.id == extension.id) { entry.client_extension = Some(extension); } continue; } entries.push(AgcExtensionSummary { kind: extension.extension_type.clone(), id: extension.id.clone(), name: extension.name.clone(), enabled: extension.enabled, builtin: false, status: extension.status.clone(), plugin: None, client_extension: Some(extension), }); } entries.sort_by(|left, right| left.name.cmp(&right.name)); Ok(entries) } pub(crate) fn start(&self, id: &str) -> Result { self.refresh()?; let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); require_plugin_adapter(id, &state.editors)?; require_plugin_project(id, &active_project)?; let editors = state.editors.clone(); let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; if !record.manifest.enabled { return Err("插件已禁用".to_string()); } if record.manifest.entry.is_none() { return Err("该 Plugin 的 Skill/MCP 使用各自的运行适配器".to_string()); } if record.running.is_some() { return self.summary_locked(record); } match spawn_plugin(&record.manifest, &record.root) { Ok(running) => { record.running = Some(running); record.status = "running".to_string(); record.last_error = None; Self::start_plugin_pump(&root, active_project, editors, record); audit(&root, id, "start", true, None); } Err(error) => { record.status = "failed".to_string(); record.last_error = Some(error.clone()); audit(&root, id, "start", false, Some("spawn-failed")); return Err(error); } } self.summary_locked(record) } pub(crate) fn stop(&self, id: &str) -> Result { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; if let Some(mut running) = record.running.take() { let _ = running.child.kill(); let _ = running.child.wait(); } record.status = if record.manifest.enabled { "stopped".to_string() } else { "disabled".to_string() }; record.last_error = None; audit(&root, id, "stop", true, None); self.summary_locked(record) } pub(crate) fn reload(&self, id: &str) -> Result { let _ = self.stop(id)?; self.refresh()?; self.start(id) } /// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。 /// /// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具 /// 也不再出现在工具列表与 Agent 上下文里。 pub(crate) fn set_enabled( &self, id: &str, enabled: bool, ) -> Result, String> { if !crate::builtin_plugins::is_builtin(id) { return Err("只有内置插件可以使用可用开关;导入扩展请使用扩展启用状态".to_string()); } let mut cleanup = Ok(()); if !enabled { let _ = self.stop(id); if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let project = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); drop(state); cleanup = crate::editor_adapters::disconnect_managed_editor_connection(editor) .and_then(|_| { crate::editor_adapters::disconnect_managed_editor_project( editor, project.as_deref(), ) }); } } crate::builtin_plugins::set_enabled(id, enabled)?; let summaries = self.refresh()?; cleanup.map_err(|error| format!("插件已禁用,编辑器资源暂未清理:{error}"))?; Ok(summaries) } pub(crate) fn read_panel( &self, id: &str, panel_id: &str, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; require_plugin_adapter(id, &state.editors)?; let record = state .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; require_plugin_project(id, &state.active_project)?; if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } let panel = Self::registrations(record) .panels .into_iter() .find(|panel| panel.id == panel_id) .or_else(|| { record .manifest .panels .iter() .find(|panel| panel.id == panel_id) .map(|panel| PluginPanelDescriptor { id: panel.id.clone(), title: panel.title.clone(), entry: panel.entry.clone(), placement: panel.placement.clone(), }) }) .ok_or_else(|| "插件面板未注册".to_string())?; let path = project_read_path( &record.root, panel.entry.strip_prefix("./").unwrap_or(&panel.entry), )?; let bytes = fs::metadata(&path) .map_err(|_| "插件面板不可读".to_string())? .len(); if bytes > MAX_RPC_BYTES as u64 { return Err("插件面板过大".to_string()); } let html = fs::read_to_string(path).map_err(|_| "插件面板不是有效文本".to_string())?; Ok(PluginPanelContent { panel, html }) } pub(crate) fn call(&self, id: &str, method: String, params: Value) -> Result { let (root, request_id, response_receiver, pending, writer, response_timeout, payload) = { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; require_plugin_project(id, &state.active_project)?; let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; let response_timeout = if record.manifest.adapter.is_some() { EDITOR_RPC_TIMEOUT } else { RPC_TIMEOUT }; let running = record .running .as_mut() .ok_or_else(|| "插件尚未启动".to_string())?; let request_id = running.next_request_id; running.next_request_id = request_id .checked_add(1) .ok_or_else(|| "插件 RPC id 已耗尽".to_string())?; let (sender, receiver) = mpsc::channel(); let payload = serialize_rpc( &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), )?; { let mut pending = running .pending .lock() .map_err(|_| "插件 RPC 等待队列锁已损坏".to_string())?; if pending.len() >= 32 { return Err("插件 RPC 并发请求过多".to_string()); } pending.insert(request_id, sender); } ( root, request_id, receiver, Arc::clone(&running.pending), Arc::clone(&running.stdin), response_timeout, payload, ) }; let deadline = Instant::now() + response_timeout; let managed_execute = crate::editor_adapters::ManagedEditor::for_plugin(id).filter(|editor| match editor { crate::editor_adapters::ManagedEditor::Unity => { method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME } crate::editor_adapters::ManagedEditor::Godot => { method == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME } }); let (write_sender, write_receiver) = mpsc::channel(); let write_phase = Arc::new(AtomicU8::new(0)); let phase = Arc::clone(&write_phase); thread::spawn(move || { let result = writer .lock() .map_err(|_| "插件 stdin 锁已损坏".to_string()) .and_then(|mut writer| write_prepared_rpc(&mut *writer, &payload, &phase)); let _ = write_sender.send(result); }); let mut result = match write_receiver.recv_timeout(RPC_TIMEOUT) { Ok(Ok(())) => { match response_receiver .recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(result) => result, Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), } } Ok(Err(error)) => Err(error), Err(_) => { cancel_rpc_before_write(&write_phase); self.terminate_rpc_instance(id, &pending); Err("插件 RPC 写入超时".to_string()) } }; if let Ok(mut pending) = pending.lock() { pending.remove(&request_id); } if let Some(editor) = managed_execute { // 最后一跳丢失同样不能通过插件重载解除执行阻断。 result = finalize_managed_plugin_result(&write_phase, result, || { crate::runner::mark_external_editor_uncertain(editor) }); } audit( &root, id, "rpc", result.is_ok(), result.as_ref().err().map(|_| "rpc-failed"), ); result } fn terminate_rpc_instance(&self, id: &str, pending: &PendingRpc) { if let Ok(mut state) = self.state.lock() { if let Some(record) = state.plugins.get_mut(id) { if record .running .as_ref() .is_some_and(|running| Arc::ptr_eq(&running.pending, pending)) { record.running = None; record.status = "failed".to_string(); record.last_error = Some("插件 RPC 写入超时".to_string()); } } } } fn start_plugin_pump( root: &Path, active_project: ProjectContext, editors: EditorRegistry, record: &mut PluginRecord, ) { let Some(running) = record.running.as_mut() else { return; }; let Some(lines) = running.lines.take() else { return; }; let writer = Arc::clone(&running.stdin); let pending = Arc::clone(&running.pending); let registrations = Arc::clone(&running.registrations); let active = Arc::clone(&running.active); let active_project = if record.id == crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID { let context = Arc::new(Mutex::new( active_project .lock() .ok() .and_then(|project| project.clone()), )); running.editor_context = Some(Arc::clone(&context)); context } else { active_project }; let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { while let Ok(line) = lines.recv() { if !active.load(Ordering::SeqCst) { break; } let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; if envelope.jsonrpc.as_deref() != Some("2.0") { continue; } if envelope.method.is_none() { if let Some(id) = envelope.id.as_ref().and_then(Value::as_u64) { if let Ok(mut waiting) = pending.lock() { if let Some(sender) = waiting.remove(&id) { let result = envelope.error.map_or_else( || { envelope .result .ok_or_else(|| "插件 RPC 缺少 result".to_string()) }, |error| Err(format!("插件 RPC 错误:{error}")), ); let _ = sender.send(result); continue; } } } } if let Some(method) = envelope.method { let response = Self::handle_host_request( &root, &active_project, &editors, &manifest, ®istrations, &method, envelope.params, ); audit( &root, &manifest.id, &method, response.is_ok(), response.as_ref().err().map(|_| "host-request-failed"), ); if let Some(id) = envelope.id { let payload = match response { Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error) => { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error}}) } }; let _ = write_rpc_shared(&writer, &payload); } } } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); for (_, sender) in remaining { let _ = sender.send(Err("插件进程已退出".to_string())); } } }); } fn handle_host_request( root: &Path, active_project: &ProjectContext, editors: &EditorRegistry, manifest: &PluginManifest, registrations: &Arc>, method: &str, params: Option, ) -> Result { if let Some(permission) = permission_for_method(method) { if !manifest.permissions.contains(permission) { audit(root, &manifest.id, method, false, Some("permission-denied")); return Err(format!("插件缺少权限:{permission}")); } } match method { "host.registerCommand" => { let input: RegisterCommandInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) || !valid_text(&input.title, 80) { return Err("命令声明无效".to_string()); } register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .commands, input.id.clone(), PluginCommandDescriptor { id: input.id, title: input.title, description: input.description, }, )?; Ok(json!({"registered": true})) } "host.unregisterCommand" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少命令 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .commands .remove(&id); Ok(json!({"unregistered": true})) } "host.registerPanel" => { let input: RegisterPanelInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) || !valid_text(&input.title, 80) { return Err("面板声明无效".to_string()); } validate_relative_path(&input.entry)?; register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .panels, input.id.clone(), PluginPanelDescriptor { id: input.id, title: input.title, entry: input.entry, placement: input.placement, }, )?; Ok(json!({"registered": true})) } "host.unregisterPanel" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少面板 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .panels .remove(&id); Ok(json!({"unregistered": true})) } "host.registerCapability" => { let input: RegisterCapabilityInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) { return Err("能力声明无效".to_string()); } register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .capabilities, input.id.clone(), PluginCapabilityDescriptor { id: input.id, description: input.description, }, )?; Ok(json!({"registered": true})) } "host.unregisterCapability" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少能力 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .capabilities .remove(&id); Ok(json!({"unregistered": true})) } "host.events.subscribe" => { let event_type = params .as_ref() .and_then(|params| params.get("type")) .and_then(Value::as_str) .filter(|name| valid_text(name, 120)) .ok_or_else(|| "事件名称无效".to_string())?; let id = format!("sub-{}", uuid::Uuid::new_v4().simple()); register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions, id.clone(), event_type.to_string(), )?; let project_path = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone() .map(|path| path.to_string_lossy().into_owned()); Ok(json!({"subscriptionId": id, "projectPath": project_path})) } "host.events.unsubscribe" => { let id = params .as_ref() .and_then(|params| params.get("subscriptionId")) .and_then(Value::as_str) .ok_or_else(|| "缺少订阅 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions .remove(id); Ok(json!({"unsubscribed": true})) } "host.project.read" => { let input: ProjectReadInput = descriptor_from_params(params)?; let project = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone() .ok_or_else(|| "尚未设置当前项目".to_string())?; let path = project_read_path(&project, &input.path)?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取项目文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_RPC_BYTES as u64 { return Err("项目文件不可读或超过大小限制".to_string()); } let content = fs::read_to_string(&path) .map_err(|error| format!("读取项目文件失败:{error}"))?; Ok(json!({"path": input.path, "content": content})) } "host.rpc" => { require_plugin_adapter(&manifest.id, editors)?; if crate::builtin_plugins::toggle_state(&manifest.id) == Some(false) { return Err("插件已禁用".to_string()); } let input: EditorRpcInput = descriptor_from_params(params)?; let adapter = manifest .adapter .as_deref() .ok_or_else(|| "插件未指定编辑器适配器".to_string())?; if input .adapter .as_deref() .is_some_and(|requested| requested != adapter) { return Err("插件不能覆盖 manifest 声明的编辑器适配器".to_string()); } // 不在全局锁后排队,避免外层已超时的写操作稍后才派发。 let editors = editors .try_lock() .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; let editor = editors .get(adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))?; let project = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())?; if !plugin_matches_project(&manifest.id, project.as_deref()) { return Err("编辑器插件与当前项目类型不匹配".to_string()); } let project = project .as_deref() .ok_or_else(|| "尚未设置当前项目".to_string())?; let params = controlled_editor_params(project, input.params)?; editor.rpc(&input.method, params) } _ => Err(format!("宿主不支持 RPC 方法:{method}")), } } fn summary_locked(&self, record: &PluginRecord) -> Result { let registrations = Self::registrations(record); let mut panels = record .manifest .panels .iter() .map(|panel| { ( panel.id.clone(), PluginPanelDescriptor { id: panel.id.clone(), title: panel.title.clone(), entry: panel.entry.clone(), placement: panel.placement.clone(), }, ) }) .collect::>(); for panel in registrations.panels { panels.insert(panel.id.clone(), panel); } Ok(PluginSummary { id: record.id.clone(), name: record.manifest.name.clone(), version: record.manifest.version.clone(), api_version: record.manifest.api_version.clone(), enabled: record.manifest.enabled, builtin: crate::builtin_plugins::is_builtin(&record.id), has_runtime: record.manifest.entry.is_some(), status: record.status.clone(), adapter: record.manifest.adapter.clone(), permissions: record.manifest.permissions.iter().cloned().collect(), commands: registrations.commands, panels: panels.into_values().collect(), capabilities: registrations.capabilities, last_error: record.last_error.clone(), }) } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { self.set_active_project_with_cleanup(project_path, |previous| { if previous.is_some() { crate::editor_adapters::disconnect_managed_editor_project( crate::editor_adapters::ManagedEditor::Godot, previous, )?; } Ok(()) }) } fn set_active_project_with_cleanup( &self, project_path: Option, cleanup: impl FnOnce(Option<&Path>) -> Result<(), String>, ) -> Result<(), String> { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let project = project_path .map(|path| { let path = PathBuf::from(path); if !path.is_dir() { return Err("项目路径必须是目录".to_string()); } path.canonicalize() .map_err(|_| "项目目录不可读".to_string()) }) .transpose()?; let previous = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); if previous != project { let mut editors = state .editors .try_lock() .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; if let Some(editor) = editors.get_mut("cocos-editor") { editor.disconnect(); } crate::editor_adapters::disconnect_unity_editor_connection(); drop(editors); // Godot 的受管桥独占原项目上下文;先撤销旧授权,再发布新项目。 for record in state .plugins .values_mut() .filter(|record| record.id == crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID) { if let Some(running) = record.running.take() { drop(running); } if record.manifest.enabled { record.status = "stopped".to_string(); } } } *state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone(); for record in state.plugins.values() { if let Some(running) = record.running.as_ref() { let subscribed = running .registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions .values() .any(|name| name == "project.changed"); if subscribed { let _ = write_rpc_shared( &running.stdin, &json!({ "jsonrpc":"2.0", "method":"host.event", "params":{ "type":"project.changed", "payload":{"projectPath": state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_ref() .map(|path| path.to_string_lossy().into_owned())}, }, }), ); } } } drop(state); if previous != project { cleanup(previous.as_deref()) .map_err(|error| format!("当前项目已切换,旧编辑器桥仍待清理:{error}"))?; } Ok(()) } pub(crate) fn register_editor_adapter( &self, adapter: Box, ) -> Result<(), String> { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; if editors.contains_key(adapter.id()) { return Err("编辑器适配器已注册".to_string()); } editors.insert(adapter.id().to_string(), adapter); Ok(()) } pub(crate) fn detect_editor( &self, adapter: String, project_path: String, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .detect(Path::new(project_path.trim())) } pub(crate) fn connect_editor( &self, adapter: String, pid: u32, project_path: String, version: String, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .connect(pid, Path::new(project_path.trim()), version.trim()) } pub(crate) fn disconnect_editor(&self, adapter: String) -> Result<(), String> { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; if adapter == "godot-editor" { if !editors.contains_key(&adapter) { return Err(format!("未知编辑器适配器:{adapter}")); } let project = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); drop(editors); drop(state); return crate::editor_adapters::disconnect_managed_editor_project( crate::editor_adapters::ManagedEditor::Godot, project.as_deref(), ); } editors .get_mut(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .disconnect(); Ok(()) } pub(crate) fn translate_editor_rpc( &self, adapter: String, method: String, params: Value, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .translate_rpc(&method, params) } } #[tauri::command] pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { host.list() } #[tauri::command] pub(crate) fn list_agc_extensions( host: State<'_, PluginHost>, ) -> Result, String> { host.list_extensions() } #[tauri::command] pub(crate) fn refresh_agc_plugins( host: State<'_, PluginHost>, ) -> Result, String> { host.refresh() } #[tauri::command] pub(crate) fn start_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.start(id.trim()) } #[tauri::command] pub(crate) fn stop_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.stop(id.trim()) } #[tauri::command] pub(crate) fn reload_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.reload(id.trim()) } #[tauri::command] pub(crate) fn set_agc_plugin_enabled( id: String, enabled: bool, host: State<'_, PluginHost>, ) -> Result, String> { host.set_enabled(id.trim(), enabled) } #[tauri::command] pub(crate) async fn call_agc_plugin( id: String, method: String, params: Value, app: tauri::AppHandle, ) -> Result { if !valid_text(&method, 120) || method.chars().any(char::is_control) { return Err("插件 RPC 方法无效".to_string()); } tauri::async_runtime::spawn_blocking(move || { app.state::().call(id.trim(), method, params) }) .await .map_err(|_| "插件 RPC 任务失败".to_string())? } #[tauri::command] pub(crate) fn read_agc_plugin_panel( id: String, panel_id: String, host: State<'_, PluginHost>, ) -> Result { host.read_panel(&id, &panel_id) } #[tauri::command] pub(crate) async fn set_agc_plugin_project_path( project_path: Option, app: tauri::AppHandle, ) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { app.state::().set_active_project(project_path) }) .await .map_err(|_| "切换插件项目上下文任务失败".to_string())? } #[cfg(test)] mod tests { use super::*; #[test] fn writer_receipt_loss_after_json_write_is_persistently_uncertain() { let config = tempfile::tempdir().unwrap(); let phase = AtomicU8::new(0); let payload = serialize_rpc(&json!({"jsonrpc":"2.0","id":1,"method":"godot.editor.execute","params":{"code":"return 42"}})).unwrap(); let mut sink = Vec::new(); write_prepared_rpc(&mut sink, &payload, &phase).unwrap(); // JSON 已完整到达对端,但 writer 的最后一跳完成通知丢失。 assert_eq!(sink, payload); assert!(!cancel_rpc_before_write(&phase)); let result = finalize_managed_plugin_result(&phase, Err("writer 回执丢失".into()), || { crate::editor_adapters::mark_editor_execution_uncertain_at( crate::editor_adapters::ManagedEditor::Godot, config.path(), ) }) .unwrap(); assert_eq!(result["status"], "needs-reconciliation"); assert!(crate::editor_adapters::editor_uncertain_fence_path( crate::editor_adapters::ManagedEditor::Godot, config.path() ) .exists()); } #[test] fn writer_cancelled_before_start_never_dispatches_later() { let phase = AtomicU8::new(0); assert!(cancel_rpc_before_write(&phase)); let mut sink = Vec::new(); assert!(write_prepared_rpc(&mut sink, b"{}\n", &phase).is_err()); assert!(sink.is_empty()); assert!( finalize_managed_plugin_result(&phase, Err("未发送".into()), || panic!( "不应标记已派发" )) .is_err() ); assert!(serialize_rpc(&json!({"code":"x".repeat(MAX_RPC_BYTES)})).is_err()); } #[test] fn project_switch_cleanup_failure_keeps_new_project_and_revokes_old_editor_process() { let _guard = crate::builtin_plugins::test_lock(); let config = tempfile::tempdir().unwrap(); let old = tempfile::tempdir().unwrap(); let new = tempfile::tempdir().unwrap(); fs::write(old.path().join("project.godot"), "config_version=5\n").unwrap(); fs::write(new.path().join("project.godot"), "config_version=5\n").unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let host = PluginHost::default(); host.initialize(config.path()).unwrap(); host.register_editor_adapter(Box::new(StubManagedAdapter("godot-editor"))) .unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); host.set_active_project_with_cleanup(Some(old.path().to_string_lossy().into()), |_| Ok(())) .unwrap(); host.start("agc-godot-editor").unwrap(); let context = host.state.lock().unwrap().plugins["agc-godot-editor"] .running .as_ref() .unwrap() .editor_context .clone() .unwrap(); let result = host .set_active_project_with_cleanup(Some(new.path().to_string_lossy().into()), |_| { Err("旧桥清理失败".into()) }); assert!(result.unwrap_err().contains("已切换")); let state = host.state.lock().unwrap(); assert_eq!( *state.active_project.lock().unwrap(), Some(new.path().canonicalize().unwrap()) ); assert!(state.plugins["agc-godot-editor"].running.is_none()); assert!(context.lock().unwrap().is_none()); } use tempfile::tempdir; fn manifest() -> PluginManifest { PluginManifest { id: "sample-plugin".to_string(), name: "Sample".to_string(), version: "1.0.0".to_string(), api_version: PLUGIN_API_VERSION.to_string(), entry: Some("index.js".to_string()), permissions: ["ui.register".to_string()].into_iter().collect(), enabled: true, adapter: None, panels: Vec::new(), } } fn write_fixture(plugin: &Path, script: &str) { fs::create_dir_all(plugin).expect("plugin directory"); fs::write( plugin.join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": "sample-plugin", "version": "1.0.0", "extensions": { "world.genarrative.agc": { "entry": "index.js", "permissions": ["ui.register"] } } }) .to_string(), ) .expect("manifest"); fs::write(plugin.join("index.js"), script).expect("entry"); } fn import_fixture(config: &Path, plugin: &Path) -> String { crate::client_extensions::import_client_extension_at( &config.join("extensions"), plugin, &fs::metadata(plugin).expect("metadata"), ) .expect("import plugin") .imported .into_iter() .find(|item| item.extension_type == "plugin") .expect("plugin item") .id } #[test] fn validates_manifest_and_rejects_traversal() { let mut value = manifest(); assert!(validate_manifest(&value).is_ok()); value.entry = Some("../escape.js".to_string()); assert!(validate_manifest(&value).is_err()); } #[test] fn scans_and_lists_plugin_manifest() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture(&plugin, "process.stdin.resume();"); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); let list = host.list().expect("list"); assert_eq!(list.len(), 1); assert_eq!(list[0].id, id); } fn write_workspace_plugin(workspace: &Path, name: &str, enabled: bool) { let plugin = workspace.join(name); fs::create_dir_all(&plugin).expect("plugin directory"); fs::write( plugin.join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": name, "version": "1.0.0", "extensions": { "world.genarrative.agc": { "entry": "index.js", "permissions": ["ui.register"], "enabled": enabled } } }) .to_string(), ) .expect("manifest"); fs::write(plugin.join("index.js"), "process.stdin.resume();").expect("entry"); } #[test] fn scans_plugins_workspace_and_honors_manifest_enabled_flag() { let directory = tempdir().expect("temp config"); let workspace = directory.path().join("workspace"); write_workspace_plugin(&workspace, "sample-plugin", true); write_workspace_plugin(&workspace, "disabled-plugin", false); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); assert!(host.list().expect("list before workspace").is_empty()); host.set_plugin_workspace(workspace.clone()) .expect("set workspace"); let list = host.list().expect("list after workspace"); assert_eq!(list.len(), 2); let enabled = list .iter() .find(|plugin| plugin.id == "sample-plugin") .expect("enabled plugin"); assert!(enabled.enabled); assert_eq!(enabled.status, "stopped"); assert_eq!(enabled.adapter, None); let disabled = list .iter() .find(|plugin| plugin.id == "disabled-plugin") .expect("disabled plugin"); assert!(!disabled.enabled); assert_eq!(disabled.status, "disabled"); } #[test] fn denies_ungranted_host_registration() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture(&plugin, "process.stdin.resume();"); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); let mut state = host.state.lock().expect("host lock"); let record = state.plugins.get_mut(&id).expect("record"); let context = ProjectContext::default(); let editors = EditorRegistry::default(); let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); assert!(PluginHost::handle_host_request( directory.path().join("extensions").as_path(), &context, &editors, &record.manifest, ®istrations, "host.project.read", None ) .is_err()); } #[test] fn portable_skill_only_package_does_not_require_a_process_entry() { let directory = tempdir().expect("temp plugin"); fs::write( directory.path().join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": "skills-only" }) .to_string(), ) .expect("manifest"); let parsed = read_plugin_manifest(directory.path()).expect("portable manifest"); assert!(parsed.entry.is_none()); assert_eq!(parsed.id, "skills-only"); } #[test] fn process_handles_idle_registration_rpc_and_stop() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture( &plugin, r#" const readline = require('node:readline'); const send = value => process.stdout.write(JSON.stringify(value) + '\n'); readline.createInterface({ input: process.stdin }).on('line', line => { const message = JSON.parse(line); if (message.method === 'echo') send({ jsonrpc: '2.0', id: message.id, result: message.params }); }); setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', params: { id: 'hello', title: 'Hello' } }), 100); "#, ); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); host.start(&id).expect("start"); let deadline = std::time::Instant::now() + Duration::from_secs(3); loop { if host.list().expect("list")[0].commands.len() == 1 { break; } assert!( std::time::Instant::now() < deadline, "idle registration was not dispatched" ); thread::sleep(Duration::from_millis(25)); } assert_eq!( host.call(&id, "echo".to_string(), json!({"ok": true})) .expect("RPC"), json!({"ok": true}) ); assert_eq!( host.call(&id, "echo".to_string(), Value::Null) .expect("null RPC result"), Value::Null ); assert_eq!(host.stop(&id).expect("stop").status, "stopped"); } #[test] fn output_without_a_bounded_newline_is_rejected() { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_RPC_BYTES + 1]); assert!(read_bounded_rpc_line(&mut reader).is_err()); } #[test] fn editor_rpc_enforces_manifest_adapter_and_current_project_without_queueing() { let root = tempdir().unwrap(); let other = tempdir().unwrap(); let project = Arc::new(Mutex::new(Some(root.path().to_path_buf()))); let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([( "cocos-editor".to_string(), Box::new(StubCocosAdapter::default()) as Box, )]))); let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); let mut manifest = manifest(); manifest.adapter = Some("cocos-editor".to_string()); manifest.permissions.insert("editor.rpc".to_string()); let call = |params| { PluginHost::handle_host_request( root.path(), &project, &editors, &manifest, ®istrations, "host.rpc", Some(params), ) }; assert!(call(json!({"adapter":"unity-editor","method":"editor.execute","params":{"code":"return 1;"}})).unwrap_err().contains("manifest")); assert!(call(json!({"method":"editor.execute","params":{"projectPath":other.path(),"code":"return 1;"}})).unwrap_err().contains("受控项目")); let result = call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).unwrap(); assert_eq!( result["params"]["projectPath"], root.path() .canonicalize() .unwrap() .to_string_lossy() .as_ref() ); let _busy = editors.lock().unwrap(); let start = std::time::Instant::now(); assert!(call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).is_err()); assert!(start.elapsed() < Duration::from_millis(100)); } #[derive(Default)] struct StubCocosAdapter { disconnects: Arc, } struct StubManagedAdapter(&'static str); impl EditorAdapter for StubManagedAdapter { fn id(&self) -> &'static str { self.0 } fn detect(&self, _project_path: &Path) -> Result { Ok(EditorConnectionInfo::disconnected(self.0)) } fn connect( &mut self, _pid: u32, _project_path: &Path, _version: &str, ) -> Result { Err("test does not connect".to_string()) } fn disconnect(&mut self) {} fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) } fn rpc(&self, _method: &str, params: Value) -> Result { Ok( json!({"ok":true,"status":"completed","retryAllowed":false,"dispatched":true,"result":{"code":params["code"],"projectPath":params["projectPath"]}}), ) } } #[test] fn workspace_unity_plugin_round_trips_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let config = tempdir().unwrap(); let project = tempdir().unwrap(); for directory in ["Assets", "Packages", "ProjectSettings"] { fs::create_dir(project.path().join(directory)).unwrap(); } fs::write( project.path().join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.1f1", ) .unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let host = PluginHost::default(); host.initialize(config.path()).unwrap(); host.register_editor_adapter(Box::new(StubManagedAdapter("unity-editor"))) .unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); host.start("agc-unity-editor").unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { if host.list().unwrap().iter().any(|plugin| { plugin.id == "agc-unity-editor" && plugin.commands.len() == 1 && plugin.capabilities.len() == 1 }) { break; } assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(20)); } let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"] .running .as_ref() .unwrap() .child .id(); host.set_active_project(Some(project.path().to_string_lossy().into_owned())) .unwrap(); let response = host .call( "agc-unity-editor", "unity.editor.execute".to_string(), json!({"code":"return 2;"}), ) .unwrap(); assert_eq!(response["status"], "completed"); assert_eq!( response["result"]["projectPath"], project .path() .canonicalize() .unwrap() .to_string_lossy() .as_ref() ); let other = tempdir().unwrap(); host.set_active_project(Some(other.path().to_string_lossy().into_owned())) .unwrap(); let response = host .call( "agc-unity-editor", "unity.editor.execute".to_string(), json!({"code":"return 3;"}), ) .unwrap(); assert_eq!(response["status"], "completed"); assert_eq!( response["result"]["projectPath"], other .path() .canonicalize() .unwrap() .to_string_lossy() .as_ref() ); host.set_active_project(None).unwrap(); assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); assert_eq!( host.state.lock().unwrap().plugins["agc-unity-editor"] .running .as_ref() .unwrap() .child .id(), plugin_pid ); let response = host .call( "agc-unity-editor", "unity.editor.execute".to_string(), json!({"code":"return 4;"}), ) .unwrap(); assert_eq!(response["status"], "failed"); assert_eq!(response["dispatched"], false); host.stop("agc-unity-editor").unwrap(); } #[test] fn workspace_godot_plugin_round_trips_and_stops_when_leaving_project() { let (plugin_id, adapter_id, execute_tool) = ("agc-godot-editor", "godot-editor", "godot.editor.execute"); let _guard = crate::builtin_plugins::test_lock(); let config = tempdir().unwrap(); let project = tempdir().unwrap(); fs::write(project.path().join("project.godot"), "config_version=5\n").unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let host = PluginHost::default(); host.initialize(config.path()).unwrap(); host.register_editor_adapter(Box::new(StubManagedAdapter(adapter_id))) .unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); assert!(!host .list() .unwrap() .iter() .any(|plugin| plugin.id == plugin_id)); host.set_active_project(Some(project.path().to_string_lossy().into_owned())) .unwrap(); host.start(plugin_id).unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { if host.list().unwrap().iter().any(|plugin| { plugin.id == plugin_id && plugin.commands.len() == 1 && plugin.capabilities.len() == 1 }) { break; } assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(20)); } let response = host .call( plugin_id, execute_tool.to_string(), json!({"code":"return 2"}), ) .unwrap(); assert_eq!( response["status"], "completed", "Godot RPC 回执:{response}" ); assert_eq!( response["result"]["projectPath"], project .path() .canonicalize() .unwrap() .to_string_lossy() .as_ref() ); host.set_active_project(None).unwrap(); assert!(!host .list() .unwrap() .iter() .any(|plugin| plugin.id == plugin_id)); assert!(host.state.lock().unwrap().plugins[plugin_id] .running .is_none()); } impl EditorAdapter for StubCocosAdapter { fn id(&self) -> &'static str { "cocos-editor" } fn detect(&self, _project_path: &Path) -> Result { Err("stub adapter 不探测进程".to_string()) } fn connect( &mut self, _pid: u32, _project_path: &Path, _version: &str, ) -> Result { Err("stub adapter 不建立连接".to_string()) } fn disconnect(&mut self) { self.disconnects.fetch_add(1, Ordering::SeqCst); } fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) } fn rpc(&self, method: &str, params: Value) -> Result { // 与 native 适配器的 CocosEditorCommandResponse 同形,供插件入口判断 status。 Ok(json!({"ok": true, "method": method, "params": params})) } } #[test] fn builtin_plugin_toggle_controls_availability() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); fs::write( directory.path().join("package.json"), r#"{"creator":{"version":"3.8.8"}}"#, ) .expect("cocos package"); fs::create_dir(directory.path().join("assets")).expect("cocos assets"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) .expect("set cocos project"); let summary = |list: Vec| { list.into_iter() .find(|plugin| plugin.id == "agc-cocos-editor") .expect("built-in plugin") }; let enabled = summary(host.list().expect("list")); assert!(enabled.builtin); assert!(enabled.enabled); let disabled = summary( host.set_enabled("agc-cocos-editor", false) .expect("disable built-in plugin"), ); assert!(!disabled.enabled); assert_eq!(disabled.status, "disabled"); assert!(host.start("agc-cocos-editor").is_err()); assert!(host .set_enabled("imported-extension", false) .expect_err("imported extensions use the extension index") .contains("只有内置插件")); let re_enabled = summary( host.set_enabled("agc-cocos-editor", true) .expect("enable built-in plugin"), ); assert!(re_enabled.enabled); assert_eq!(re_enabled.status, "stopped"); } #[test] fn workspace_cocos_plugin_round_trips_editor_rpc() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); fs::write( directory.path().join("package.json"), r#"{"creator":{"version":"3.8.8"}}"#, ) .expect("cocos package"); fs::create_dir(directory.path().join("assets")).expect("cocos assets"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); let project = fs::canonicalize(directory.path()) .expect("canonical project") .to_string_lossy() .into_owned(); host.set_active_project(Some(project.clone())) .expect("set active project"); host.start("agc-cocos-editor").expect("start plugin"); let deadline = std::time::Instant::now() + Duration::from_secs(15); loop { let registered = host.list().expect("list").into_iter().any(|plugin| { plugin.id == "agc-cocos-editor" && plugin.commands.len() == 2 && plugin.capabilities.len() == 1 && plugin.panels.len() == 1 }); if registered { break; } assert!( std::time::Instant::now() < deadline, "Cocos 插件未在期限内完成注册" ); thread::sleep(Duration::from_millis(25)); } let response = host .call( "agc-cocos-editor", "cocos.editor.execute".to_string(), json!({"code": "return 1 + 1;"}), ) .expect("cocos execute rpc"); assert_eq!(response["status"], "completed"); assert_eq!(response["response"]["method"], "editor.execute"); assert_eq!(response["response"]["params"]["projectPath"], project); assert_eq!(response["response"]["params"]["code"], "return 1 + 1;"); assert_eq!( host.stop("agc-cocos-editor").expect("stop plugin").status, "stopped" ); } #[test] fn cocos_plugin_stays_available_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); let adapter = StubCocosAdapter::default(); let disconnects = Arc::clone(&adapter.disconnects); host.register_editor_adapter(Box::new(adapter)) .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); assert!(host .list() .expect("list plugins") .into_iter() .any(|plugin| plugin.id == "agc-cocos-editor")); host.start("agc-cocos-editor") .expect("start without a project"); let deadline = Instant::now() + Duration::from_secs(15); while host.read_panel("agc-cocos-editor", "cocos-editor").is_err() || !host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .as_ref() .unwrap() .registrations .lock() .unwrap() .subscriptions .values() .any(|event| event == "project.changed") { assert!(Instant::now() < deadline, "Cocos panel was not registered"); thread::sleep(Duration::from_millis(25)); } let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .as_ref() .unwrap() .child .id(); let project = tempdir().expect("web project"); host.set_active_project(Some(project.path().to_string_lossy().into_owned())) .expect("set active project"); assert_eq!(disconnects.load(Ordering::SeqCst), 1); host.set_active_project(Some(project.path().to_string_lossy().into_owned())) .expect("keep the same active project"); assert_eq!(disconnects.load(Ordering::SeqCst), 1); let response = host .call( "agc-cocos-editor", "cocos.editor.execute".to_string(), json!({"code":"return 1;"}), ) .expect("RPC reaches the adapter without a project type gate"); assert_eq!(response["status"], "completed"); assert_eq!( response["response"]["params"]["projectPath"], project .path() .canonicalize() .unwrap() .to_string_lossy() .as_ref() ); host.set_active_project(None).unwrap(); assert_eq!(disconnects.load(Ordering::SeqCst), 2); assert!(host .list_extensions() .unwrap() .iter() .any(|plugin| plugin.id == "agc-cocos-editor")); host.read_panel("agc-cocos-editor", "cocos-editor") .expect("panel remains available"); assert_eq!( host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .as_ref() .unwrap() .child .id(), plugin_pid ); assert!(host .call( "agc-cocos-editor", "cocos.editor.execute".to_string(), json!({"code":"return 1;"}), ) .is_err()); host.stop("agc-cocos-editor").unwrap(); } #[test] fn cocos_plugin_requires_registered_adapter_even_for_a_cocos_project() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); fs::write( directory.path().join("package.json"), r#"{"creator":{"version":"3.8.8"}}"#, ) .unwrap(); fs::create_dir(directory.path().join("assets")).unwrap(); crate::builtin_plugins::initialize(directory.path()).unwrap(); let host = PluginHost::default(); host.initialize(directory.path()).unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) .unwrap(); assert!(host .list() .unwrap() .iter() .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host .list_extensions() .unwrap() .iter() .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host .start("agc-cocos-editor") .err() .expect("unsupported adapter") .contains("不支持 Cocos")); assert!(host .read_panel("agc-cocos-editor", "cocos-editor") .err() .expect("unsupported adapter") .contains("不支持 Cocos")); assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .is_none()); host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .unwrap(); assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-cocos-editor")); } }