288e1d4f4a
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 13s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 13s
Project CI / AI game creator shell Rust smoke (push) Failing after 12s
Project CI / AI game creator shell Rust crates (push) Failing after 20s
Project CI / Backend tests (push) Failing after 23s
Project CI / Native shell tests (push) Failing after 12s
Project CI / Frontend tests (push) Failing after 5s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 13s
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新 Godot 插件先接收受控项目快照再注册命令和连接能力 补充启动竞态回归、失败回执诊断及插件启动合同
2913 lines
105 KiB
Rust
2913 lines
105 KiB
Rust
//! 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<Mutex<BTreeMap<String, Box<dyn EditorAdapter>>>>;
|
||
type ProjectContext = Arc<Mutex<Option<PathBuf>>>;
|
||
type PendingRpc = Arc<Mutex<BTreeMap<u64, Sender<Result<Value, String>>>>>;
|
||
|
||
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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
#[serde(default)]
|
||
pub(crate) permissions: BTreeSet<String>,
|
||
#[serde(default = "default_enabled")]
|
||
pub(crate) enabled: bool,
|
||
#[serde(default)]
|
||
pub(crate) adapter: Option<String>,
|
||
#[serde(default)]
|
||
pub(crate) panels: Vec<PluginPanelManifest>,
|
||
}
|
||
|
||
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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct PluginCapabilityDescriptor {
|
||
pub(crate) id: String,
|
||
pub(crate) description: Option<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub(crate) permissions: Vec<String>,
|
||
pub(crate) commands: Vec<PluginCommandDescriptor>,
|
||
pub(crate) panels: Vec<PluginPanelDescriptor>,
|
||
pub(crate) capabilities: Vec<PluginCapabilityDescriptor>,
|
||
pub(crate) last_error: Option<String>,
|
||
}
|
||
|
||
/// 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<PluginSummary>,
|
||
pub(crate) client_extension: Option<crate::client_extensions::ClientExtensionItem>,
|
||
}
|
||
|
||
struct RunningPlugin {
|
||
child: Child,
|
||
#[cfg(windows)]
|
||
_job: crate::process_session::WindowsProcessJob,
|
||
stdin: Arc<Mutex<ChildStdin>>,
|
||
lines: Option<Receiver<String>>,
|
||
pending: PendingRpc,
|
||
registrations: Arc<Mutex<PluginRegistrations>>,
|
||
next_request_id: u64,
|
||
editor_context: Option<ProjectContext>,
|
||
active: Arc<AtomicBool>,
|
||
}
|
||
|
||
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<String, PluginCommandDescriptor>,
|
||
panels: BTreeMap<String, PluginPanelDescriptor>,
|
||
capabilities: BTreeMap<String, PluginCapabilityDescriptor>,
|
||
subscriptions: BTreeMap<String, String>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct PluginRegistrationsSnapshot {
|
||
commands: Vec<PluginCommandDescriptor>,
|
||
panels: Vec<PluginPanelDescriptor>,
|
||
capabilities: Vec<PluginCapabilityDescriptor>,
|
||
}
|
||
|
||
fn register_entry<T>(
|
||
entries: &mut BTreeMap<String, T>,
|
||
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<String>,
|
||
running: Option<RunningPlugin>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct PluginHostState {
|
||
root: Option<PathBuf>,
|
||
workspace: Option<PathBuf>,
|
||
plugins: BTreeMap<String, PluginRecord>,
|
||
active_project: ProjectContext,
|
||
editors: EditorRegistry,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct PluginHost {
|
||
state: Mutex<PluginHostState>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct RpcEnvelope {
|
||
#[serde(default)]
|
||
jsonrpc: Option<String>,
|
||
#[serde(default)]
|
||
id: Option<Value>,
|
||
#[serde(default)]
|
||
method: Option<String>,
|
||
#[serde(default)]
|
||
params: Option<Value>,
|
||
#[serde(default, deserialize_with = "deserialize_rpc_result")]
|
||
result: Option<Value>,
|
||
#[serde(default)]
|
||
error: Option<Value>,
|
||
}
|
||
|
||
fn deserialize_rpc_result<'de, D: serde::Deserializer<'de>>(
|
||
deserializer: D,
|
||
) -> Result<Option<Value>, 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<PathBuf, String> {
|
||
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<PathBuf> {
|
||
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<String>,
|
||
name: String,
|
||
#[serde(default)]
|
||
version: Option<String>,
|
||
#[serde(default)]
|
||
extensions: BTreeMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||
struct AgcRuntimeExtension {
|
||
#[serde(default)]
|
||
api_version: Option<String>,
|
||
#[serde(default)]
|
||
entry: Option<String>,
|
||
#[serde(default)]
|
||
permissions: BTreeSet<String>,
|
||
#[serde(default = "default_enabled")]
|
||
enabled: bool,
|
||
#[serde(default)]
|
||
adapter: Option<String>,
|
||
#[serde(default)]
|
||
panels: Vec<PluginPanelManifest>,
|
||
}
|
||
|
||
pub(crate) fn read_plugin_manifest(root: &Path) -> Result<PluginManifest, String> {
|
||
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::<RawPluginManifest>(&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::<AgcRuntimeExtension>(
|
||
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<PathBuf, String> {
|
||
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<PathBuf> {
|
||
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<bool>,
|
||
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<Vec<ScannedPluginSource>, 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<RunningPlugin, String> {
|
||
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<Option<String>, 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<Vec<u8>, 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<Value, String>,
|
||
mark_uncertain: impl FnOnce() -> Result<(), String>,
|
||
) -> Result<Value, String> {
|
||
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<Mutex<ChildStdin>>, value: &Value) -> Result<(), String> {
|
||
let mut stdin = stdin
|
||
.lock()
|
||
.map_err(|_| "插件 stdin 锁已损坏".to_string())?;
|
||
write_rpc(&mut stdin, value)
|
||
}
|
||
|
||
fn descriptor_from_params<T: for<'de> Deserialize<'de>>(
|
||
params: Option<Value>,
|
||
) -> Result<T, String> {
|
||
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<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct RegisterPanelInput {
|
||
id: String,
|
||
title: String,
|
||
entry: String,
|
||
#[serde(default)]
|
||
placement: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct RegisterCapabilityInput {
|
||
id: String,
|
||
#[serde(default)]
|
||
description: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ProjectReadInput {
|
||
path: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct EditorRpcInput {
|
||
#[serde(default)]
|
||
adapter: Option<String>,
|
||
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<bool, String> {
|
||
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<Value, String> {
|
||
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::<Vec<_>>();
|
||
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<Vec<PluginSummary>, 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<Vec<PluginSummary>, String> {
|
||
self.list()
|
||
}
|
||
|
||
pub(crate) fn list_extensions(&self) -> Result<Vec<AgcExtensionSummary>, 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::<Vec<_>>();
|
||
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<PluginSummary, String> {
|
||
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<PluginSummary, String> {
|
||
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<PluginSummary, String> {
|
||
let _ = self.stop(id)?;
|
||
self.refresh()?;
|
||
self.start(id)
|
||
}
|
||
|
||
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
|
||
///
|
||
/// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具
|
||
/// 也不再出现在工具列表与 Agent 上下文里。
|
||
pub(crate) fn set_enabled(
|
||
&self,
|
||
id: &str,
|
||
enabled: bool,
|
||
) -> Result<Vec<PluginSummary>, 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<PluginPanelContent, String> {
|
||
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<Value, String> {
|
||
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::<RpcEnvelope>(&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<Mutex<PluginRegistrations>>,
|
||
method: &str,
|
||
params: Option<Value>,
|
||
) -> Result<Value, String> {
|
||
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<PluginSummary, String> {
|
||
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::<BTreeMap<_, _>>();
|
||
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<String>) -> 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<String>,
|
||
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<dyn EditorAdapter>,
|
||
) -> 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<EditorConnectionInfo, String> {
|
||
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<EditorConnectionInfo, String> {
|
||
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<Value, String> {
|
||
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<Vec<PluginSummary>, String> {
|
||
host.list()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn list_agc_extensions(
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<Vec<AgcExtensionSummary>, String> {
|
||
host.list_extensions()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn refresh_agc_plugins(
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<Vec<PluginSummary>, String> {
|
||
host.refresh()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn start_agc_plugin(
|
||
id: String,
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<PluginSummary, String> {
|
||
host.start(id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn stop_agc_plugin(
|
||
id: String,
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<PluginSummary, String> {
|
||
host.stop(id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn reload_agc_plugin(
|
||
id: String,
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<PluginSummary, String> {
|
||
host.reload(id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn set_agc_plugin_enabled(
|
||
id: String,
|
||
enabled: bool,
|
||
host: State<'_, PluginHost>,
|
||
) -> Result<Vec<PluginSummary>, 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<Value, String> {
|
||
if !valid_text(&method, 120) || method.chars().any(char::is_control) {
|
||
return Err("插件 RPC 方法无效".to_string());
|
||
}
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
app.state::<PluginHost>().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<PluginPanelContent, String> {
|
||
host.read_panel(&id, &panel_id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn set_agc_plugin_project_path(
|
||
project_path: Option<String>,
|
||
app: tauri::AppHandle,
|
||
) -> Result<(), String> {
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
app.state::<PluginHost>().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<dyn EditorAdapter>,
|
||
)])));
|
||
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<std::sync::atomic::AtomicUsize>,
|
||
}
|
||
|
||
struct StubManagedAdapter(&'static str);
|
||
|
||
impl EditorAdapter for StubManagedAdapter {
|
||
fn id(&self) -> &'static str {
|
||
self.0
|
||
}
|
||
fn detect(&self, _project_path: &Path) -> Result<EditorConnectionInfo, String> {
|
||
Ok(EditorConnectionInfo::disconnected(self.0))
|
||
}
|
||
fn connect(
|
||
&mut self,
|
||
_pid: u32,
|
||
_project_path: &Path,
|
||
_version: &str,
|
||
) -> Result<EditorConnectionInfo, String> {
|
||
Err("test does not connect".to_string())
|
||
}
|
||
fn disconnect(&mut self) {}
|
||
fn translate_rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
|
||
Ok(params)
|
||
}
|
||
fn rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
|
||
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<EditorConnectionInfo, String> {
|
||
Err("stub adapter 不探测进程".to_string())
|
||
}
|
||
|
||
fn connect(
|
||
&mut self,
|
||
_pid: u32,
|
||
_project_path: &Path,
|
||
_version: &str,
|
||
) -> Result<EditorConnectionInfo, String> {
|
||
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<Value, String> {
|
||
Ok(params)
|
||
}
|
||
|
||
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
|
||
// 与 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<PluginSummary>| {
|
||
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"));
|
||
}
|
||
}
|