55375b1406
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m30s
Project CI / Backend tests (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m12s
Project CI / Native shell tests (push) Successful in 5m49s
Project CI / Frontend tests (push) Successful in 2m18s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m50s
Project CI / AI game creator shell web tests (push) Failing after 2m11s
Project CI / Repository checks (push) Successful in 2m55s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/509 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
2736 lines
100 KiB
Rust
2736 lines
100 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 crate::editor_adapter::EditorAdapter;
|
|
|
|
#[cfg(not(test))]
|
|
mod desktop;
|
|
#[cfg(not(test))]
|
|
pub(crate) use desktop::*;
|
|
|
|
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)
|
|
}
|
|
|
|
/// 统一后的插件来源,屏蔽“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)
|
|
}
|
|
|
|
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
|
|
///
|
|
/// 该状态同时被 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(())
|
|
}
|
|
|
|
#[cfg(any(
|
|
test,
|
|
all(windows, feature = "cocos-editor-execute"),
|
|
all(
|
|
windows,
|
|
target_arch = "x86_64",
|
|
any(feature = "unity-editor-execute", feature = "godot-editor-execute")
|
|
)
|
|
))]
|
|
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(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::editor_adapter::EditorConnectionInfo;
|
|
|
|
#[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
|
|
}) && host.state.lock().unwrap().plugins["agc-unity-editor"]
|
|
.running
|
|
.as_ref()
|
|
.unwrap()
|
|
.registrations
|
|
.lock()
|
|
.unwrap()
|
|
.subscriptions
|
|
.values()
|
|
.any(|event| event == "project.changed")
|
|
{
|
|
// 后续切换项目会发送事件;先订阅,避免注册完成后丢失项目通知。
|
|
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 {
|
|
// 注册完成早于项目订阅快照到达;只读查询确认插件已收到上下文。
|
|
match host.call(
|
|
"agc-cocos-editor",
|
|
"cocos.editor.connection".to_string(),
|
|
json!({"operation": "status", "processId": 1}),
|
|
) {
|
|
Ok(response) => {
|
|
assert_eq!(response["params"]["projectPath"], project);
|
|
break;
|
|
}
|
|
Err(error) if error.contains("当前没有受控项目路径") => {}
|
|
Err(error) => panic!("cocos context readiness rpc: {error}"),
|
|
}
|
|
}
|
|
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"));
|
|
}
|
|
}
|