Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs
T
kdletters 5822b64d7c 合并 Godot 编辑器插件与常用操作指导到主分支
接入 Godot 原生桥、受控执行、Runner 回执与编辑器操作指南
保留主分支 Cocos 和 Unity 跨工程能力及外置提示词结构
解决插件生命周期、工具目录、前端启动和文档合并冲突
2026-09-20 17:38:48 +08:00

559 lines
20 KiB
Rust

//! 内置插件登记表与可用开关。
//!
//! 内置插件随 `plugins/` 工作区随包分发,用户不能卸载,只能控制是否可用。
//! 开关状态持久化在 AppData `extensions/builtin-plugins.json`,同时被两处消费:
//! 插件宿主(禁用后不能启动,状态显示为 disabled)和 Agent 工具目录(禁用后
//! 不出现在工具列表与 Agent 上下文里)。
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Cocos Creator 编辑器插件的插件 id,与 `plugins/agc-cocos-editor/plugin.json` 一致。
pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor";
/// 该插件在 Agent 侧对应的 Runtime 工具名。
pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute";
pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor";
pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute";
pub(crate) const AGC_GODOT_EDITOR_PLUGIN_ID: &str = "agc-godot-editor";
pub(crate) const AGC_GODOT_EDITOR_TOOL_NAME: &str = "godot.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BuiltinPlugin {
CocosEditor,
UnityEditor,
GodotEditor,
}
impl BuiltinPlugin {
pub(crate) fn id(self) -> &'static str {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID,
Self::GodotEditor => AGC_GODOT_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[
BuiltinPlugin::CocosEditor,
BuiltinPlugin::UnityEditor,
BuiltinPlugin::GodotEditor,
];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
.iter()
.copied()
.find(|plugin| plugin.id() == id.trim())
}
pub(crate) fn is_builtin(id: &str) -> bool {
builtin_plugin(id).is_some()
}
#[derive(Debug, Default)]
struct BuiltinPluginState {
path: Option<PathBuf>,
enabled: BTreeMap<String, bool>,
/// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。
fail_closed: bool,
}
static STATE: OnceLock<Mutex<BuiltinPluginState>> = OnceLock::new();
fn state() -> &'static Mutex<BuiltinPluginState> {
STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default()))
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct BuiltinPluginStateFile {
#[serde(default)]
schema_version: Option<String>,
#[serde(default)]
enabled: BTreeMap<String, bool>,
}
/// 读取 AppData 里的开关状态;文件缺失按默认状态处理,坏文件失败关闭。
pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> {
let root = config_dir.join("extensions");
let path = root.join(STATE_FILE_NAME);
if let Err(error) = fs::create_dir_all(&root) {
mark_fail_closed(Some(path));
return Err(format!("准备内置插件目录失败:{error}"));
}
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
guard.path = Some(path.clone());
reload_state(&mut guard, &path)
}
fn read_state_file(path: &Path) -> Result<BuiltinPluginStateFile, String> {
match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<BuiltinPluginStateFile>(&bytes) {
Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => Ok(file),
Ok(_) => Err(format!(
"内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}"
)),
Err(error) => Err(format!("内置插件开关文件无效:{error}")),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(BuiltinPluginStateFile::default())
}
Err(error) => Err(format!("读取内置插件开关失败:{error}")),
}
}
fn reload_state(guard: &mut BuiltinPluginState, path: &Path) -> Result<(), String> {
let loaded = match read_state_file(path) {
Ok(loaded) => loaded,
Err(error) => {
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
return Err(error);
}
};
guard.fail_closed = false;
// 只接受登记表里的 id,避免坏文件把未知对象带进运行时。
guard.enabled = loaded
.enabled
.into_iter()
.filter(|(id, _)| is_builtin(id))
.collect();
Ok(())
}
fn mark_fail_closed(path: Option<PathBuf>) {
if let Ok(mut guard) = state().lock() {
guard.path = path;
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
}
}
pub(crate) fn is_enabled(id: &str) -> bool {
let Some(plugin) = builtin_plugin(id) else {
return false;
};
// Runner/CLI 已绑定配置根,但不会执行 GUI setup;不推断隔离子进程的 AppData。
let config_dir = crate::game_creator_runtime_config_dir_lock()
.lock()
.ok()
.and_then(|path| path.clone());
state()
.lock()
.map(|mut guard| {
let Some(path) = guard
.path
.clone()
.or_else(|| config_dir.map(|root| root.join("extensions").join(STATE_FILE_NAME)))
else {
return false;
};
// 每次查询读取持久化权威,使运行中的其它进程立即感知开关变化。
let _ = reload_state(&mut guard, &path);
if guard.fail_closed {
return false;
}
guard
.enabled
.get(plugin.id())
.copied()
.unwrap_or_else(|| plugin.default_enabled())
})
// 状态锁损坏时同样 fail-closed,避免异常状态重新放开内置能力。
.unwrap_or(false)
}
/// 内置插件的用户开关;`None` 表示该 id 不是内置插件,由来源自己决定启用状态。
pub(crate) fn toggle_state(id: &str) -> Option<bool> {
builtin_plugin(id).map(|_| is_enabled(id))
}
pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result<bool, String> {
let Some(plugin) = builtin_plugin(id) else {
return Err(format!("{id} 不是内置插件,不能使用内置插件开关"));
};
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
// 保留其它进程刚写入的开关;损坏文件按全部禁用起步,允许用户显式修复。
let _ = reload_state(&mut guard, &path);
let previous = guard.enabled.get(plugin.id()).copied();
guard.enabled.insert(plugin.id().to_string(), enabled);
if let Err(error) = persist(&guard) {
match previous {
Some(value) => {
guard.enabled.insert(plugin.id().to_string(), value);
}
None => {
guard.enabled.remove(plugin.id());
}
}
return Err(error);
}
guard.fail_closed = false;
Ok(enabled)
}
fn persist(guard: &BuiltinPluginState) -> Result<(), String> {
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
let file = BuiltinPluginStateFile {
schema_version: Some(STATE_SCHEMA_VERSION.to_string()),
enabled: guard.enabled.clone(),
};
let bytes = serde_json::to_vec_pretty(&file)
.map_err(|error| format!("序列化内置插件开关失败:{error}"))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, bytes).map_err(|error| format!("写入内置插件开关失败:{error}"))?;
fs::rename(&temporary, &path).map_err(|error| format!("提交内置插件开关失败:{error}"))?;
Ok(())
}
/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。
pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
plugin.exposes_agent_tools()
&& match plugin {
BuiltinPlugin::CocosEditor => cfg!(all(windows, feature = "cocos-editor-execute")),
BuiltinPlugin::UnityEditor => {
cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
)) && unity_editor_bridge::is_supported_platform()
}
BuiltinPlugin::GodotEditor => {
cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
)) && godot_editor_bridge::is_supported_platform()
}
}
&& is_enabled(plugin.id())
}
pub(crate) fn cocos_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::CocosEditor)
}
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
let mut available = Vec::new();
if cocos_editor_agent_tool_available() {
let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME];
tools.extend(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.filter_map(|tool| tool["name"].as_str()),
);
available.extend(tools);
}
if unity_editor_agent_tool_available() {
available.push(AGC_UNITY_EDITOR_TOOL_NAME);
}
if godot_editor_agent_tool_available() {
available.push(AGC_GODOT_EDITOR_TOOL_NAME);
}
available
}
/// DirectProject 项目工具目录:Cocos/Unity 不按工程类型过滤,Godot 保持受控项目合同。
pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> {
available_agent_tools()
.into_iter()
.filter(|tool| {
*tool != AGC_GODOT_EDITOR_TOOL_NAME
|| godot_editor_agent_tool_available_for_project(root)
})
.collect()
}
pub(crate) fn unity_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::UnityEditor)
}
pub(crate) fn godot_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::GodotEditor)
}
pub(crate) fn godot_editor_agent_tool_available_for_project(root: &Path) -> bool {
godot_editor_agent_tool_available()
&& crate::project::discover_local_godot_project_root(root)
.ok()
.flatten()
.is_some()
}
#[cfg(test)]
pub(crate) use tests::test_lock;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn godot_tools_follow_real_subproject_and_independent_toggle() {
let _guard = test_lock();
let config = tempdir().unwrap();
initialize(config.path()).unwrap();
let project = tempdir().unwrap();
fs::create_dir(project.path().join("game")).unwrap();
fs::write(
project.path().join("game/project.godot"),
"config_version=5\n",
)
.unwrap();
let supported = cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
));
assert_eq!(
available_agent_tools_for_project(project.path()).contains(&AGC_GODOT_EDITOR_TOOL_NAME),
supported
);
set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, false).unwrap();
assert!(!available_agent_tools_for_project(project.path())
.contains(&AGC_GODOT_EDITOR_TOOL_NAME));
assert!(is_enabled(AGC_UNITY_EDITOR_PLUGIN_ID));
set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, true).unwrap();
fs::create_dir(project.path().join("other")).unwrap();
fs::write(
project.path().join("other/project.godot"),
"config_version=5\n",
)
.unwrap();
assert!(!godot_editor_agent_tool_available_for_project(
project.path()
));
}
#[test]
fn unity_tool_visibility_requires_platform_and_independent_toggle() {
let _guard = test_lock();
let config = tempdir().unwrap();
initialize(config.path()).unwrap();
let supported = cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
));
assert_eq!(
available_agent_tools().contains(&AGC_UNITY_EDITOR_TOOL_NAME),
supported
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
assert_eq!(unity_editor_agent_tool_available(), supported);
set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap();
assert!(!unity_editor_agent_tool_available());
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
assert!(!unity_editor_agent_tool_available());
}
#[test]
fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert!(is_builtin(AGC_COCOS_EDITOR_PLUGIN_ID));
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(true));
assert_eq!(toggle_state("imported-plugin"), None);
assert!(!is_enabled("imported-plugin"));
assert!(set_enabled("imported-plugin", false).is_err());
}
#[test]
fn toggle_state_round_trips_through_appdata_file() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert_eq!(
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"),
false
);
let path = directory.path().join("extensions").join(STATE_FILE_NAME);
let written = fs::read_to_string(&path).expect("state file");
assert!(written.contains(STATE_SCHEMA_VERSION));
assert!(written.contains(AGC_COCOS_EDITOR_PLUGIN_ID));
// 重新初始化模拟下次启动读取持久化结果。
initialize(directory.path()).expect("re-initialize");
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn corrupt_state_file_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(root.join(STATE_FILE_NAME), "{ not json").expect("write corrupt state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false));
}
#[test]
fn enabling_after_corruption_recovers_without_reinitializing() {
let _guard = test_lock();
let directory = tempdir().unwrap();
fs::create_dir_all(directory.path().join("extensions")).unwrap();
fs::write(
directory.path().join("extensions/builtin-plugins.json"),
"{",
)
.unwrap();
assert!(initialize(directory.path()).is_err());
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn availability_reloads_changes_written_by_another_process() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
let path = directory.path().join("extensions/builtin-plugins.json");
for enabled in [false, true, false] {
fs::write(
&path,
serde_json::json!({
"schemaVersion": STATE_SCHEMA_VERSION,
"enabled": {AGC_COCOS_EDITOR_PLUGIN_ID: enabled}
})
.to_string(),
)
.unwrap();
assert_eq!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID), enabled);
}
fs::write(&path, "{").unwrap();
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn runner_process_reads_disabled_state_without_gui_setup() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"builtin_plugins::tests::runner_process_probe",
"--nocapture",
])
.env("AGC_BUILTIN_TEST_CONFIG_DIR", directory.path())
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
}
#[test]
fn runner_process_probe() {
let Some(config_dir) = std::env::var_os("AGC_BUILTIN_TEST_CONFIG_DIR") else {
return;
};
// Runner/CLI 只绑定配置根目录,不进入 Tauri GUI setup。
crate::set_game_creator_runtime_config_dir(PathBuf::from(config_dir));
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn unsupported_schema_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(
root.join(STATE_FILE_NAME),
serde_json::json!({"schemaVersion": "agc.builtin-plugins.v0", "enabled": {}})
.to_string(),
)
.expect("write unsupported state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn agent_tool_visibility_follows_the_toggle() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
let tool_visible_when_enabled = cfg!(all(windows, feature = "cocos-editor-execute"));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert_eq!(
cocos_editor_agent_tool_available(),
tool_visible_when_enabled
);
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable");
assert!(!cocos_editor_agent_tool_available());
assert!(
!crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME)
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("re-enable");
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
}
}