补齐并公开客户端 Cocos 编辑器操作能力
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 1m53s
Project CI / Native shell tests (pull_request) Failing after 4m49s

新增统一目录和 36 个 Cocos 场景、组件、UI、资源与预览操作
复用内置桥接并接通 DirectProject MCP 工具发现和调用
补充事务回滚、撤销、首次保存与预览错误采集
增加真实 Creator smoke、构造器一致性测试和使用文档
This commit is contained in:
2026-09-13 12:28:10 +08:00
parent 9107d887ed
commit 8b6f821c3c
26 changed files with 4812 additions and 26 deletions
File diff suppressed because one or more lines are too long
@@ -2345,6 +2345,15 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
bridge_cocos_call(state, arguments, None).await
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_call(
state: &DirectToolBridgeState,
arguments: &Value,
operation: Option<&str>,
) -> Value {
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return bridge_tool_result(
"Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(),
@@ -2353,8 +2362,21 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value)
);
}
let prepared = (|| {
bridge_reject_unknown_fields(arguments, &["code"])?;
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
if let Some(operation) = operation {
let tool = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|tool| tool["name"] == operation)
.ok_or_else(|| "未知 Cocos 操作".to_string())?;
let validator = jsonschema::validator_for(&tool["inputSchema"])
.map_err(|e| format!("Cocos schema 错误:{e}"))?;
if let Err(error) = validator.validate(arguments) {
return Err(format!("Cocos 参数无效:{error}"));
}
return cocos_editor_bridge::build_cocos_operation_code(operation, arguments)
.map_err(|e| e.to_string());
}
bridge_reject_unknown_fields(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
@@ -2372,7 +2394,11 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value)
)
}
};
let mut uncertain = state.cocos_execute_uncertain.lock().await;
let Ok(mut uncertain) = state.cocos_execute_uncertain.try_lock() else {
return bridge_tool_result(
json!({"status":"failed","retryAllowed":false,"message":"已有 Cocos 操作执行中,请等待回执"}).to_string(),
Vec::new(), true);
};
if *uncertain {
return bridge_tool_result(
json!({
@@ -2385,6 +2411,11 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value)
);
}
let root = state.root.clone();
let timeout_ms = if operation.is_some() {
60_000
} else {
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS
};
let result = tokio::task::spawn_blocking(move || {
// Cocos execute talks to the already-open Creator process through its
// validated Inspector/pipe bridge. It does not mutate AGC's project
@@ -2398,23 +2429,63 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value)
cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(),
&code,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
timeout_ms,
)
})
.await;
match result {
Ok(Ok(response)) => {
let is_error = !response.ok;
let mut result = response.result;
let status = if operation.is_some() {
result
.as_ref()
.and_then(|v| v["status"].as_str())
.unwrap_or(if response.ok { "completed" } else { "failed" })
.to_string()
} else if response.ok {
"completed".to_string()
} else {
"failed".to_string()
};
let is_error = !response.ok || status != "completed";
if status == "needs-reconciliation" {
*uncertain = true;
}
// 截图以 MCP image block 返回,不能被文本截断破坏 base64。
let mut images = Vec::new();
if operation == Some("cocos_preview_debug_capture") {
if let Some(value) = result
.as_mut()
.and_then(|v| v.get_mut("result"))
.and_then(Value::as_object_mut)
{
if let Some(image) = value.remove("__image") {
if image["mimeType"] == "image/png" {
if let Some(data) = image["data"].as_str() {
images.push(data.to_string());
}
}
}
}
}
let text = json!({
"status": if response.ok { "completed" } else { "failed" },
"status": status,
"requestId": response.request_id,
"result": response.result,
"result": result,
"error": response.error,
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(&state.root, &text, 32_000),
Vec::new(),
redact_agent_runtime_project_paths(
&state.root,
&text,
if operation.is_some() {
2 * 1024 * 1024
} else {
32_000
},
),
images,
is_error,
)
}
@@ -2468,6 +2539,10 @@ async fn handle_direct_tool_bridge(
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
bridge_cocos_call(&state, &request.arguments, Some(operation)).await
}
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
@@ -468,6 +468,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"additionalProperties": false
}
}));
tools.extend(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.cloned(),
);
}
if controlled_web_search {
tools.push(json!({
@@ -1650,6 +1655,10 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
"agc_write_file" => call_agc_write_file(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => call_agc_cocos_execute(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
call_client_tool_bridge(operation, &arguments).await
}
"taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await,
"agc_generate_image" => call_agc_generate_image(&arguments).await,
"agc_edit_image" => call_agc_edit_image(&arguments).await,
@@ -1863,6 +1872,17 @@ mod tests {
.any(|tool| tool["name"] == "agc_cocos_execute"),
expected == "true"
);
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.filter(|tool| tool["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
if expected == "true" { 36 } else { 0 }
);
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
@@ -1905,6 +1925,16 @@ mod tests {
enabled
);
if !enabled {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_client_tool_bridge(tool["name"].as_str().unwrap(), &json!({})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("插件已禁用"));
}
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
@@ -1957,6 +1987,114 @@ mod tests {
}
use std::io::{Read, Write};
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[tokio::test]
#[ignore = "显式指定已打开的自有 Cocos smoke 工程后运行"]
async fn cocos_real_tools_list_and_call() {
let _guard = crate::builtin_plugins::test_lock();
let root =
PathBuf::from(std::env::var("AGC_COCOS_TEST_PROJECT").expect("explicit smoke root"))
.canonicalize()
.unwrap();
let package: Value =
serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap();
assert_eq!(
package["name"], "agc-cocos-capability-smoke",
"只操作自有测试工程"
);
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
std::fs::create_dir_all(root.join(".agent")).unwrap();
if !root.join(".agent/manifest.json").exists() {
std::fs::write(root.join(".agent/manifest.json"), "{}").unwrap();
}
let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(&root, false)
.await
.unwrap();
let tools = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
tools["tools"]
.as_array()
.unwrap()
.iter()
.filter(|t| t["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
36
);
async fn invoke(root: &Path, url: &str, name: &str, args: Value) -> Value {
let reply = EXTERNAL_MCP_BRIDGE_URL.scope(url.to_string(), handle_direct_tools_mcp_request(root, json!({
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args}
}))).await.unwrap();
assert_eq!(reply["result"]["isError"], false, "{name}: {reply}");
reply["result"].clone()
}
let ping = invoke(&root, bridge.url(), "cocos_ping", json!({})).await;
assert!(ping.to_string().contains("ready"));
let hierarchy = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
let parsed: Value =
serde_json::from_str(hierarchy["content"][0]["text"].as_str().unwrap()).unwrap();
let tree = &parsed["result"]["result"]["tree"];
let canvas = tree["children"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "Canvas")
.unwrap();
invoke(
&root,
bridge.url(),
"cocos_create_ui_label",
json!({
"parentNid":canvas["uuid"], "name":"AGC_MCP_SMOKE", "text":"MCP", "save":false
}),
)
.await;
invoke(&root, bridge.url(), "cocos_mcp_undo_last", json!({})).await;
if let Ok(url) = std::env::var("AGC_COCOS_TEST_PREVIEW_URL") {
invoke(
&root,
bridge.url(),
"cocos_preview_debug_start",
json!({"url":url}),
)
.await;
let capture = invoke(
&root,
bridge.url(),
"cocos_preview_debug_capture",
json!({}),
)
.await;
assert!(capture["content"]
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "image" && b["mimeType"] == "image/png"));
invoke(&root, bridge.url(), "cocos_preview_debug_stop", json!({})).await;
}
let final_tree = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
assert!(!final_tree.to_string().contains("AGC_MCP_SMOKE"));
}
#[test]
fn cocos_mcp_schemas_accept_real_arguments_and_reject_unknown_fields() {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let validator = jsonschema::validator_for(&tool["inputSchema"]).unwrap();
assert!(!validator.is_valid(&json!({"unregistered-field":1})));
}
let ui = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|t| t["name"] == "cocos_apply_ui_spec")
.unwrap();
let validator = jsonschema::validator_for(&ui["inputSchema"]).unwrap();
assert!(validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"container","children":[{"kind":"label","text":"测试"}]}],"save":false})));
assert!(!validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"shell-command"}]})));
}
#[tokio::test]
async fn loopback_tool_bridge_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture");
@@ -2061,6 +2199,17 @@ mod tests {
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.chain(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.filter_map(|tool| {
if cfg!(all(windows, feature = "cocos-editor-execute")) {
tool["name"].as_str()
} else {
None
}
})
)
.collect::<Vec<_>>()
);
let serialized = specs.to_string();
@@ -247,7 +247,13 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool {
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
if cocos_editor_agent_tool_available() {
vec![AGC_COCOS_EDITOR_TOOL_NAME]
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()),
);
tools
} else {
Vec::new()
}
@@ -2079,7 +2079,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
loop {
let registered = host.list().expect("list").into_iter().any(|plugin| {
plugin.id == "agc-cocos-editor"
&& plugin.commands.len() == 1
&& plugin.commands.len() == 2
&& plugin.capabilities.len() == 1
&& plugin.panels.len() == 1
});
@@ -1,5 +1,14 @@
# 踩坑与排障记录
## 2026-09-13 Cocos 操作必须核对实际回执与引擎就绪状态
- named pipe 使用真实换行分帧;测试客户端若写入字面量反斜杠 n,服务端不会执行请求。不能仅凭这类超时推断 Scene WebView 卡死,更不能重放不确定写操作。
- Scene WebView 可直接运行内置 JS;使用 `require('cc')` 完整模块,旧全局 `cc` 并不包含所有构造器(例如 UITransform)。
- AssetDB reimport 返回时 SpriteFrame 可能仍不可加载;先有界预加载,再开始事务。首次场景保存使用 AssetDB 创建、等待导入、标记快照已保存和官方 open-scene,不对未命名场景调用会弹窗的 save-scene。
- 独立预览 BrowserWindow 先加载 about:blank 建立 renderer,再启用 CDP;截止时间必须覆盖初始化和导航全部步骤,失败只关闭自有窗口。
- 工具注册、JS 正常返回和真实编辑成功是三种不同证据。UI 必须创建组件/持久资源并回读,不能把空节点或固定 verified:true 当成完成。
- 桌面测试指定 `cargo test --bin genarrative-ai-game-creator-shell`,避免默认多目标构建尝试覆盖正在运行的客户端 EXE;Windows 临时目录 owner/DACL 失败单独报告,不当成本次功能回归。
## 2026-09-12 Cocos 请求不得回退到项目内 MCP 扩展
AGC 的 Cocos 能力来自随客户端分发的 `agc-cocos-editor` 内置插件,工具名为 `cocos.editor.execute` / `agc_cocos_execute`。Cocos 项目中的 `extensions/`、`package.json` 插件声明和第三方 MCP 包不是桥接来源;内置工具不可用时必须报告客户端插件状态,不能扫描、安装、启用或要求用户打开项目内 MCP 面板。
@@ -1,5 +1,15 @@
# AGC Cocos Creator 编辑器桥接模块
## 2026-09-13 内置操作目录实施合同
- 交付:在插件包内实现 36 个 `cocos_*` 操作,以同一份 JSON Schema 目录供插件宿主和 DirectProject `agc_tools` 使用;保留通用 execute。目录与实现一起随客户端编译分发,不依赖项目扩展或开发机目录。
- 实现:`src/operations/` 保存操作目录、主进程编排、场景运行时和预览运行时;JS 入口与 native crate 复用同一代码构造器。DirectProject 在现有执行权限和结果不确定阻断之下调用,不引入第二套连接或项目写锁。
- 身份:场景查询返回真实 UUID 和本次 Creator 场景会话内的 NID;场景切换后旧 NID 不复用。默认写操作先校验,再读取结果;只有确实完成回读才返回 verified。
- 事务:普通节点/组件操作和批量 UI 保存变更前后的场景序列化状态,失败恢复变更前状态;MCP 撤销只接受场景当前状态仍等于对应操作的后状态,避免覆盖用户后续修改。编辑器 undo 仍走官方撤销。
- UI:支持 64 个节点、12 层、Label/Sprite/Button/持久 Shape、Layout/Widget/九宫格,超限在写入前拒绝;首次场景保存可指定 assets 下新路径。模板资产由 AssetDB 导入,禁止手工写场景 JSON。
- 预览:插件托管独立 Electron BrowserWindow,收集控制台/JS 异常和网络失败、截图并关闭自有窗口;仅允许当前项目本地预览地址,不附着或终止用户浏览器。
- 验收:插件行为测试、native 目录/构造器一致性测试、DirectProject tools/list 与 tools/call 开关测试、真实 Creator 查询/创建/组件/UI/回滚/撤销/保存/预览验证分开报告。桌面集成测试只构建测试目标,避免覆盖运行中的客户端 EXE。
## 目标
用户打开 Cocos Creator 项目后,AGC 在正确的 Creator 主进程内安装随包 JavaScript bootstrap;用户无需手动安装项目扩展。核心位于 `plugins/agc-cocos-editor/native/cocos-editor-bridge`,通过 Node Inspector 引导、通过 named pipe 执行业务代码。
@@ -42,7 +52,7 @@ cocos-editor-bridge = { path = ".../plugins/agc-cocos-editor/native/cocos-editor
AGC 不再内置 Cocos 专属 Tauri 命令。适配器 `cocos-editor` 由插件包 `plugins/agc-cocos-editor` 提供,实现通用 `EditorAdapter`(`prepare` / `inject` / `ping` / `status` / `execute` / `detect` / `connect` / `disconnect`),由宿主按 manifest 的 `adapter` 字段注册;插件入口通过 `host.rpc` 触发这些操作,宿主校验 `editor.rpc` 权限后路由到 native 模块。
Runtime 只广告 `cocos.editor.execute`,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;具体权限沿用已有 Runtime 策略。Windows 的标准 dev/release 脚本统一默认选择 `cocos-editor-execute`,它包含 `windows-bootstrap`;显式 feature 参数优先。纯 Cargo 默认 feature 仍为空。进程发现只用于内部目标校验,不建立客户端扫描服务,不向模型公开 PID、端口或 Inspector。
插件宿主提供 `cocos.editor.execute` 和 `cocos.editor.operation`;DirectProject 的 `agc_tools` 从插件目录注册全部 36 个独立 `cocos_*` 工具,保留 `agc_cocos_execute`。两条入口使用同一份 `src/operations/catalog.json` 和 JS 源码,native crate 以 include_str 编译进客户端。版本指纹由源码与构造模板共同决定,JS/Rust 构造器测试保证一致。目标 PID 由当前项目唯一匹配;权限检查、禁用开关和不确定结果阻断共用现有链路。执行中的第二个操作会立即被拒绝,不积压稍后发送的写请求。
## 插件包形态
@@ -57,7 +67,7 @@ plugins/agc-cocos-editor/
宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `<resource_dir>/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,Runtime 工具 `cocos.editor.execute` 与 DirectProject 的 `agc_cocos_execute` 同时从工具目录、工具策略快照和 Agent 上下文里消失;重新启用后立即恢复。开关状态保存在 AppData `extensions/builtin-plugins.json`。
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,通用 execute 和全部 `cocos_*` 工具从 Agent 目录消失;直接请求隐藏工具也会在宿主执行前被拒绝。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态。
## AGC 项目打开入口
@@ -70,7 +80,11 @@ Cocos Creator 项目,并将其标记为 `cocos` 项目类型。选择目录后
## 第一阶段命令协议
DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用插件 native 模块,执行前检查现有项目权限但不获取 `.agent/project.lock`;该入口只通过 Inspector / pipe 操作已打开的 Creator,文件写入工具仍独立使用项目锁。当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义;插件入口注册的同名命令走宿主 `host.rpc` → `EditorAdapter` 路径,两条路径共享同一 native 实现和不确定结果阻断语义。
DirectProject 与插件宿主都构造受控 JS 并调用 native executor。通用 execute 维持原期限,目录操作期限为 60 秒,涵盖资源导入和预览加载。客户端在 blocking worker 内执行,检查项目权限但不获取 `.agent/project.lock`。截图提取为 MCP image block,不能截断 base64;失败、回滚或需要核对不能因底层 JS 正常返回而被改写为 completed。
场景运行时只定位绑定 Creator 中唯一 `packages://scene/` WebView,使用 `require('cc')` 完整模块及当前场景管理器,不安装或读取项目扩展。事务保存前后序列化快照;MCP 撤销只在当前状态与事务后状态相同时恢复,已保存事务同步写回。对用户后续手动修改不做覆盖;场景切换和 Creator 重启不保留历史。
首次保存将官方序列化结果交给 AssetDB 创建新资源,等待导入可查询后标记原快照已保存,再通过官方 open-scene 读取该资源;不直接更改 UUID,不对未命名场景调用会弹对话框的 save-scene。UI 先等待 SpriteFrame 可加载,再开始场景事务;模板 PNG 保留在 assets/agc-ui-shapes 供后续复用。
注入 payload 在目标 Creator 主进程内监听 `\\.\pipe\genarrative-cocos-editor-{pid}`,使用换行分隔的 JSON。crate 只生成三种操作:
@@ -80,7 +94,7 @@ DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute`
{"schemaVersion":"game-creator-cocos-editor-bridge.v1","requestId":"cocos-42-...","processId":42,"projectPath":"C:\\demo","command":{"op":"execute","code":"return Editor.Project.path"}}
```
回执必须回传相同的 `schemaVersion/requestId/processId`。`execute.code` 是支持 `await` 和 `return` 的 JavaScript 函数体,上限为 128 KiB,单次回执上限为 2 MiB;bootstrap 通过 `AsyncFunction('Editor', 'require', code)` 在 Creator 主进程事件循环串行执行,并返回可序列化 JSON。它使用 Creator/Node 的现有权限,不是代码沙箱;AGC 不维护额外的 Cocos 业务 API 名单。
回执必须回传相同的 `schemaVersion/requestId/processId`。`execute.code` 是支持 `await` 和 `return` 的 JavaScript 函数体,上限为 128 KiB,单次回执上限为 2 MiB;bootstrap 通过 `AsyncFunction('Editor', 'require', code)` 在 Creator 主进程事件循环串行执行,并返回可序列化 JSON。它使用 Creator/Node 的现有权限,不是代码沙箱;目录操作按自身 schema 验证参数,通用 execute 仍允许自定义 JS。
Rust 客户端在写入前通过 `GetNamedPipeServerProcessId` 验证 pipe 属于目标 PID,读写使用 overlapped I/O 和 deadline。execute 开始写入后遇到断线、超时或无可信回执,返回 `ExecutionUncertain`,Runtime 进入 `needs-reconciliation`,不得自动重放。客户端超时不等于 JavaScript 已取消,bootstrap 保持同一串行队列直到原执行结束;同步死循环仍可能阻塞 Creator,需要真实集成阶段提供运行时中断方案。
+50 -4
View File
@@ -4,7 +4,7 @@ Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插
RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。
它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。用户在运行时设置里只能切换
“是否可用”,禁用后插件不能启动,`cocos.editor.execute` / `agc_cocos_execute` 也会
“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 `cocos_*` Agent 工具也会
从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。
Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于
@@ -15,6 +15,8 @@ AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的
plugin.json Agent Plugins 清单 + AGC Runtime 扩展
src/entry.mjs 运行时入口(注册命令 / 能力 / 面板,转发 host.rpc)
src/cocos-editor-adapter.mjs 通用请求 → Cocos 适配器请求的翻译与入参校验
src/operations/ 36 个操作的 schema、主进程/场景/预览实现
src/cocos-editor-operations.mjs 插件侧构造器,与 native 编译内置代码一致
panels/cocos-editor.html 自包含面板
native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pipe 协议、注入)
```
@@ -26,6 +28,8 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip
| 协议 | `agc.plugin.v1`(stdio 行分隔 JSON-RPC 2.0) |
| 适配器 id | `cocos-editor` |
| 命令 | `cocos.editor.execute`(`{ code }`) |
| 操作命令 | `cocos.editor.operation`(`{ operation: "cocos_*", args: {...} }`) |
| Agent MCP | `agc_cocos_execute` + `catalog.json` 的全部 36 个 `cocos_*` 工具 |
| 能力 | `cocos.editor.connection`(`{ operation, processId?, projectPath?, timeoutMs? }`) |
| 面板 | `cocos-editor`(`panels/cocos-editor.html`,sidebar) |
@@ -38,11 +42,53 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip
Agent 通过 `agc_cocos_execute` 注入 JavaScript 函数体;插件只负责把代码安全地送进
已校验的 Creator 主进程,不在项目目录写入扩展。常用的 Creator 3.8.8 操作如下:
客户端还注册了 `cocos.editor.operation` 操作命令。它把第三方 MCP 的 36 个稳定操作名
暴露给宿主;DirectProject 的 `agc_tools` 同时从该目录注册 36 个独立工具。两者使用相同
schema 和内置 JS,并在执行时走同一个受控 `editor.execute` 通道:
```text
cocos_ping / cocos_get_capabilities / cocos_get_project_info
cocos_get_current_scene_meta / cocos_get_log_tail / cocos_diagnose
cocos_get_build_diagnostics / cocos_list_assets / cocos_get_prefab_info
cocos_get_hierarchy / cocos_search_nodes / cocos_inspect_node
cocos_set_node_active / cocos_set_node_transform / cocos_set_node_name
cocos_create_node / cocos_delete_node / cocos_reparent_node
cocos_set_node_sibling_index / cocos_duplicate_node / cocos_instantiate_prefab
cocos_set_component_property / cocos_add_component / cocos_remove_component
cocos_create_ui_shape / cocos_create_ui_label / cocos_create_ui_sprite
cocos_create_ui_button / cocos_apply_ui_spec
cocos_preview_debug_start / cocos_preview_debug_read / cocos_preview_debug_capture
cocos_preview_debug_stop / cocos_save_scene / cocos_editor_undo / cocos_mcp_undo_last
```
场景操作由主进程定位唯一 Scene WebView,将随包 JS 加载到该运行时,调用 Creator 场景管理器
与 `require('cc')` 的引擎 API;保存和资源导入使用官方 `Editor.Message`。不安装项目扩展。
`cocos_get_hierarchy` 返回 `{ sceneUuid, tree, truncated }`,节点含 `nid/uuid`;写工具的
`nid/parentNid/childNid` 均支持查询得到的 NID 或 UUID。`cocos_inspect_node` 返回带真实索引的
组件属性。场景切换后旧 NID 不复用,需重新查询。
批量 UI 支持 container/shape/label/sprite/button、Layout、Widget、九宫格,限制为 64 个节点和
12 层(按钮文字计入节点数)。Shape 使用 AssetDB 导入的 `assets/agc-ui-shapes/` PNG 模板;
它是可复用资产缓存,撤销节点不会删除模板。`save` 缺省 true,运行态回读和保存失败都会明确报告。
首次保存缺省创建 `assets/Main.scene`,也可通过 `cocos_save_scene { path }` 指定新路径。
编辑事务保留前后序列化快照(单份 2 MiB、最多 32 次、合计 16 MiB)。MCP 撤销只在当前场景仍
等于对应后状态时恢复,已保存事务的撤销同步保存。代码更新、Creator 重启和切换场景不保留撤销历史。
`cocos_editor_undo` 使用官方 undo;最近操作属于本插件时额外核对恢复结果。
预览在插件自有、无 Node 权限的独立 Chromium 窗口运行,只接收当前 Creator 项目预览端口的
loopback URL。采集 console、JS 异常、网络失败及 HTTP 错误,截图作为 MCP PNG image 返回;
stop 只关闭该窗口。日志只读项目 `temp/logs/`、`temp/builder/`、`build/` 下的日志文件。
所有操作仍受项目身份校验、单执行队列和不确定结果禁止重放约束。实现按 Creator 3.8.8 核验;
其它 3.x 版本中缺少对应运行时 API 时会报告错误,不虚报能力已执行。
| 目的 | 示例 |
| --- | --- |
| 读取场景树 | `return await Editor.Message.request('scene', 'query-node-tree');` |
| 读取节点 | `return await Editor.Message.request('scene', 'query-node', uuid);` |
| 修改属性 | `return await Editor.Message.request('scene', 'set-property', { uuid, path, value });` |
| 修改属性 | 优先 `cocos_set_component_property`;原始 `scene/set-property` 需要从 query-node 克隆并修改属性 dump |
| 添加组件 | `return await Editor.Message.request('scene', 'create-component', { uuid, component });` |
| 执行场景脚本 | `return await Editor.Message.request('scene', 'execute-scene-script', { name, method, args });` |
| 查询资源 | `return await Editor.Message.request('asset-db', 'query-asset-info', uuid);` |
@@ -63,7 +109,7 @@ execute 不接受并发积压。结果不确定时返回 `needs-reconciliation`
`projectPath`。
- 之后宿主设置项目时推送 `project.changed` 事件,payload 带 `projectPath`。
插件不缓存凭据、不扫描文件系统;所有编辑器操作都经 `host.rpc` 交给 native 适配器,
插件不缓存凭据;资源与日志只在当前受控项目内有界读取。编辑器操作经 `host.rpc` 交给 native 适配器,
由适配器做 PID / 项目 / 版本校验。
## Native 模块
@@ -87,7 +133,7 @@ AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通
```bash
cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml
cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml --features windows-bootstrap -- --include-ignored
node --test plugins/agc-cocos-editor/src/entry.test.mjs
npm test --prefix plugins/agc-cocos-editor
```
真实 Creator 验收(注入、Inspector 引导、非空场景读写)仍按
@@ -28,7 +28,6 @@ windows-transport = [
]
windows-injection = [
"windows-transport",
"dep:sha2",
"windows-sys/Win32_System_Diagnostics_Debug",
"windows-sys/Win32_System_LibraryLoader",
"windows-sys/Win32_System_Memory",
@@ -48,7 +47,7 @@ windows-bootstrap = [
editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = { version = "0.10", optional = true }
sha2 = "0.10"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", optional = true, default-features = false }
@@ -1,6 +1,7 @@
fn main() {
println!("cargo:rerun-if-changed=native/native_payload.cpp");
println!("cargo:rerun-if-changed=payload/bootstrap.cjs");
println!("cargo:rerun-if-changed=../../src/operations");
if std::env::var_os("CARGO_CFG_WINDOWS").is_some()
&& std::env::var_os("CARGO_FEATURE_WINDOWS_INJECTION").is_some()
{
@@ -26,8 +26,10 @@ use sha2::{Digest, Sha256};
mod adapter;
#[cfg(all(windows, feature = "windows-bootstrap"))]
mod inspector;
mod operations;
pub use adapter::{CocosEditorAdapter, COCOS_EDITOR_ADAPTER_ID, COCOS_EDITOR_RPC_METHODS};
pub use operations::{build_cocos_operation_code, cocos_operation_catalog, is_cocos_operation};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
@@ -0,0 +1,138 @@
//! 插件宿主与 DirectProject 共用的能力目录、JS 实现和代码构造器。
use crate::{validate_execute_code, BridgeError};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::sync::OnceLock;
const CATALOG: &str = include_str!("../../../src/operations/catalog.json");
const TEMPLATE: &str = include_str!("../../../src/operations/invoke.js.txt");
const SOURCES: &[(&str, &str)] = &[
(
"assets.cjs",
include_str!("../../../src/operations/assets.cjs"),
),
("main.cjs", include_str!("../../../src/operations/main.cjs")),
(
"paths.cjs",
include_str!("../../../src/operations/paths.cjs"),
),
(
"preview.cjs",
include_str!("../../../src/operations/preview.cjs"),
),
(
"scene.cjs",
include_str!("../../../src/operations/scene.cjs"),
),
(
"validate.cjs",
include_str!("../../../src/operations/validate.cjs"),
),
];
pub fn cocos_operation_catalog() -> &'static Vec<Value> {
static CACHED: OnceLock<Vec<Value>> = OnceLock::new();
CACHED.get_or_init(|| serde_json::from_str(CATALOG).expect("bundled Cocos catalog"))
}
pub fn is_cocos_operation(name: &str) -> bool {
cocos_operation_catalog()
.iter()
.any(|tool| tool["name"] == name)
}
pub fn build_cocos_operation_code(operation: &str, args: &Value) -> Result<String, BridgeError> {
let tool = cocos_operation_catalog()
.iter()
.find(|tool| tool["name"] == operation)
.ok_or_else(|| BridgeError::InvalidInput("未知 Cocos 操作".into()))?;
if !args.is_object() || args.to_string().len() > 64 * 1024 {
return Err(BridgeError::InvalidInput(
"Cocos 参数必须是 64 KiB 以内的 JSON 对象".into(),
));
}
let sources =
serde_json::to_string(&SOURCES.iter().copied().collect::<BTreeMap<_, _>>()).unwrap();
let version = format!(
"{:x}",
Sha256::new()
.chain_update(&sources)
.chain_update(TEMPLATE)
.finalize()
);
let names: Vec<_> = cocos_operation_catalog()
.iter()
.map(|tool| &tool["name"])
.collect();
// 用户参数最后插入;参数中的模板标记只能作为普通字符串,不再参与替换。
let code = TEMPLATE
.replace("__AGC_SOURCES__", &sources)
.replace("__AGC_VERSION__", &serde_json::to_string(&version).unwrap())
.replace("__AGC_SCHEMA__", &tool["inputSchema"].to_string())
.replace("__AGC_NAMES__", &serde_json::to_string(&names).unwrap())
.replace(
"__AGC_OPERATION__",
&serde_json::to_string(operation).unwrap(),
)
.replace("__AGC_ARGS__", &args.to_string());
validate_execute_code(&code)?;
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_catalog_is_complete_unique_and_closed() {
let tools = cocos_operation_catalog();
assert_eq!(tools.len(), 36);
let names: std::collections::BTreeSet<_> =
tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(names.len(), tools.len());
for tool in tools {
assert_eq!(tool["inputSchema"]["additionalProperties"], false);
assert!(build_cocos_operation_code(
tool["name"].as_str().unwrap(),
&serde_json::json!({})
)
.is_ok());
}
assert!(build_cocos_operation_code("cocos_unknown", &serde_json::json!({})).is_err());
assert!(build_cocos_operation_code("cocos_ping", &Value::Null).is_err());
}
#[test]
fn operation_builder_matches_plugin_and_preserves_literal_markers() {
use std::io::Write;
use std::process::{Command, Stdio};
let args = serde_json::json!({"nid":1,"name":"中文 __AGC_SOURCES__ \" \\\n"});
let expected = build_cocos_operation_code("cocos_set_node_name", &args).unwrap();
let mut node = Command::new("node")
.current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
.args(["--input-type=module", "-e", "import fs from 'node:fs';import {buildCocosOperationCode} from './src/cocos-editor-operations.mjs';process.stdout.write(buildCocosOperationCode('cocos_set_node_name',JSON.parse(fs.readFileSync(0,'utf8'))));"])
.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
node.stdin
.take()
.unwrap()
.write_all(args.to_string().as_bytes())
.unwrap();
let result = node.wait_with_output().unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
// JSON 对象 key 的顺序不影响执行;版本必须完全相同,参数须保留字面量。
let actual = String::from_utf8(result.stdout).unwrap();
let version = |code: &str| {
code.lines()
.find(|line| line.starts_with("const version"))
.unwrap()
.to_owned()
};
assert_eq!(version(&actual), version(&expected));
assert!(actual.contains("__AGC_SOURCES__"));
assert!(expected.contains("__AGC_SOURCES__"));
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"description": "AGC Cocos Creator 编辑器插件",
"scripts": {
"test": "node --test src/entry.test.mjs"
"test": "node --test src/entry.test.mjs src/operations/operations.test.cjs"
},
"dependencies": {
"@genarrative/agc-plugin-sdk": "0.1.0"
@@ -0,0 +1,254 @@
// 对显式指定的自有测试工程执行真实操作;先用 native creator_smoke 建立桥接。
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {
buildCocosOperationCode,
COCOS_EDITOR_OPERATIONS,
} from '../src/cocos-editor-operations.mjs';
const project = fs.realpathSync(process.argv[2] || '');
const pid = Number(process.argv[3]);
assert.ok(Number.isInteger(pid) && pid > 0, '需要显式指定 Creator PID');
assert.equal(
JSON.parse(fs.readFileSync(path.join(project, 'package.json'), 'utf8')).name,
'agc-cocos-capability-smoke',
'只允许自有测试工程,不操作用户项目',
);
const report = [];
const reportPath = path.join(project, 'temp/agc-operation-smoke.json');
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
async function raw(code) {
const request = {
schemaVersion: 'game-creator-cocos-editor-bridge.v1',
requestId: randomUUID(),
processId: pid,
projectPath: project,
command: { op: 'execute', code },
};
return await new Promise((resolve, reject) => {
const socket = net.connect('\\\\.\\pipe\\genarrative-cocos-editor-' + pid);
let output = '';
socket.setTimeout(60000);
socket.on('connect', () => socket.write(JSON.stringify(request) + '\n'));
socket.on('data', (data) => {
output += data.toString();
if (!output.includes('\n')) return;
socket.destroy();
const response = JSON.parse(output.split('\n')[0]);
assert.equal(response.requestId, request.requestId);
resolve(response);
});
socket.on('error', reject);
socket.on('timeout', () => {
socket.destroy();
reject(new Error('结果不确定:禁止自动重放'));
});
socket.on('end', () => {
if (!output.includes('\n')) reject(new Error('回执不完整'));
});
});
}
async function op(name, args = {}) {
const start = Date.now();
const response = await raw(buildCocosOperationCode(name, args));
const copy = structuredClone(response);
if (copy.result?.result?.__image) copy.result.result.__image.data = '[PNG]';
report.push({ name, elapsedMs: Date.now() - start, response: copy });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
assert.equal(response.ok, true, response.error);
assert.equal(
response.result.status,
'completed',
JSON.stringify(response.result),
);
return response.result.result ?? response.result;
}
async function evalScene(source) {
const result = await raw(
"return await require('electron').webContents.getAllWebContents().find(w=>w.getURL().startsWith('packages://scene/')).executeJavaScript(" +
JSON.stringify(source) +
');',
);
assert.equal(result.ok, true, result.error);
return result.result;
}
await op('cocos_ping');
await op('cocos_get_capabilities');
await op('cocos_get_project_info');
const meta = await op('cocos_get_current_scene_meta');
assert.ok(meta.named && !meta.dirty, '测试要求已保存且无未保存修改的 2D 场景');
const mainUuid = meta.sceneAssetUuid;
const tree = await op('cocos_get_hierarchy');
const canvas = tree.tree.children.find((node) => node.name === 'Canvas');
assert.ok(canvas, '测试工程需要 Canvas');
const parent = canvas.uuid;
const baseline = await evalScene(
'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)',
);
const node = await op('cocos_create_node', {
parentNid: parent,
name: 'AGC_TestNode',
});
await op('cocos_set_node_name', { nid: node.nid, name: 'AGC_Renamed' });
await op('cocos_set_node_transform', {
nid: node.nid,
position: { x: 12, y: 34, z: 0 },
rotationEuler: { x: 0, y: 0, z: 15 },
scale: { x: 2, y: 2, z: 1 },
});
await op('cocos_set_node_active', { nid: node.nid, active: false });
const component = await op('cocos_add_component', {
nid: node.nid,
componentType: 'Label',
});
await op('cocos_set_component_property', {
nid: node.nid,
componentIndex: component.componentIndex,
property: 'string',
value: '测试',
});
await op('cocos_remove_component', {
nid: node.nid,
componentIndex: component.componentIndex,
expectedComponentType: 'Label',
});
await op('cocos_mcp_undo_last');
const clone = await op('cocos_duplicate_node', { nid: node.nid });
await op('cocos_reparent_node', { childNid: clone.nid, parentNid: node.nid });
await op('cocos_set_node_sibling_index', { nid: node.nid, index: 0 });
await op('cocos_search_nodes', {
nameSubstring: 'AGC_Renamed',
componentFilter: 'Label',
});
await op('cocos_inspect_node', { nid: node.nid });
await op('cocos_set_node_name', {
nid: node.nid,
name: 'AGC_BeforeEditorUndo',
});
await op('cocos_editor_undo');
assert.equal(
(await op('cocos_inspect_node', { nid: node.nid })).name,
'AGC_Renamed',
);
await op('cocos_delete_node', { nid: node.nid, confirm: true, cascade: true });
await op('cocos_save_scene');
for (const [kind, args] of [
['shape', { shape: 'circle', color: '#FF0000' }],
['label', { text: 'Cocos smoke', fontSize: 24 }],
['sprite', { assetPath: 'assets/agc-ui-shapes/circle.png', nineSlice: true }],
['button', { label: 'Start', color: '#0088FF' }],
]) {
await op('cocos_create_ui_' + kind, {
parentNid: parent,
name: 'AGC_UI_' + kind,
...args,
save: true,
});
await op('cocos_mcp_undo_last');
}
await op('cocos_apply_ui_spec', {
parentNid: parent,
save: true,
nodes: [
{
kind: 'container',
layout: { type: 'horizontal', spacingX: 12 },
children: [
{ kind: 'shape', shape: 'circle', color: '#FF0000' },
{ kind: 'shape', shape: 'rectangle', color: '#0000FF' },
{ kind: 'button', buttonLabel: 'Start' },
],
},
],
});
await op('cocos_mcp_undo_last');
assert.equal(
await evalScene(
'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)',
),
baseline,
);
await op('cocos_get_log_tail', { maxLines: 10 });
await op('cocos_get_build_diagnostics', { maxFiles: 2, maxLinesPerFile: 20 });
await op('cocos_diagnose', { maxLogLines: 10 });
await op('cocos_list_assets', { extensions: ['png'], maxResults: 10 });
const seed = await op('cocos_create_node', {
parentNid: parent,
name: 'AGC_PrefabSeed',
});
const data = await evalScene(
'(()=>{const cc=require("cc"),p=new cc.Prefab();p.data=cc.instantiate(cce.Node.query(' +
JSON.stringify(seed.uuid) +
'));try{return cce.Utils.serialize(p);}finally{p.data.destroy();p.destroy();}})()',
);
const prefabPath = 'assets/AGC-Smoke-' + Date.now() + '.prefab';
const asset = await raw(
"return await Editor.Message.request('asset-db','create-asset'," +
JSON.stringify('db://' + prefabPath) +
',' +
JSON.stringify(data) +
');',
);
assert.equal(asset.ok, true, asset.error);
await op('cocos_get_prefab_info', { prefabPath });
const instance = await op('cocos_instantiate_prefab', {
parentNid: parent,
prefabPath,
});
await op('cocos_delete_node', {
nid: instance.nid,
confirm: true,
cascade: true,
});
await op('cocos_delete_node', { nid: seed.nid, confirm: true, cascade: true });
await op('cocos_save_scene');
assert.equal(
(
await raw(
"return await Editor.Message.request('scene','load-empty-scene');",
)
).ok,
true,
);
await op('cocos_save_scene', {
path: 'assets/AGC-Fresh-' + Date.now() + '.scene',
});
assert.equal(
(
await raw(
"return await Editor.Message.request('scene','open-scene'," +
JSON.stringify(mainUuid) +
');',
)
).ok,
true,
);
const urls = await raw(
"return require('electron').webContents.getAllWebContents().filter(w=>w.getType()==='webview').map(w=>w.getURL()).filter(url=>url.startsWith('http://localhost:'));",
);
const url = new URL(urls.result[0]).origin + '/';
await op('cocos_preview_debug_start', { url });
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
await op('cocos_preview_debug_read', { maxEvents: 30 });
const capture = await op('cocos_preview_debug_capture');
assert.equal(capture.__image.mimeType, 'image/png');
fs.writeFileSync(
path.join(project, 'temp/agc-preview-smoke.png'),
Buffer.from(capture.__image.data, 'base64'),
);
} finally {
await op('cocos_preview_debug_stop');
}
const names = new Set(report.map((item) => item.name));
assert.deepEqual([...names].sort(), [...COCOS_EDITOR_OPERATIONS].sort());
console.log('36/36 Cocos operations passed; report: ' + reportPath);
@@ -0,0 +1,51 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const validate = require('./operations/validate.cjs');
const directory = new URL('./operations/', import.meta.url);
const read = (name) => fs.readFileSync(new URL(name, directory), 'utf8');
export const COCOS_OPERATION_CATALOG = Object.freeze(
JSON.parse(read('catalog.json')),
);
export const COCOS_EDITOR_OPERATIONS = Object.freeze(
COCOS_OPERATION_CATALOG.map((tool) => tool.name),
);
const template = read('invoke.js.txt');
const sources = Object.fromEntries(
[
'assets.cjs',
'main.cjs',
'paths.cjs',
'preview.cjs',
'scene.cjs',
'validate.cjs',
].map((name) => [name, read(name)]),
);
const sourcesJson = JSON.stringify(sources);
const version = createHash('sha256')
.update(sourcesJson)
.update(template)
.digest('hex');
export function buildCocosOperationCode(operation, args = {}) {
const tool = COCOS_OPERATION_CATALOG.find((item) => item.name === operation);
if (!tool) throw new Error('不支持的 Cocos 操作:' + operation);
validate(tool.inputSchema, args);
const replacements = {
__AGC_SOURCES__: sourcesJson,
__AGC_VERSION__: JSON.stringify(version),
__AGC_SCHEMA__: JSON.stringify(tool.inputSchema),
__AGC_NAMES__: JSON.stringify(COCOS_EDITOR_OPERATIONS),
__AGC_OPERATION__: JSON.stringify(operation),
__AGC_ARGS__: JSON.stringify(args),
};
const code = template.replace(
/__AGC_[A-Z]+__/g,
(marker) => replacements[marker],
);
if (Buffer.byteLength(code) > 128 * 1024)
throw new Error('Cocos 操作超过代码传输上限');
return code;
}
+39 -1
View File
@@ -18,9 +18,14 @@ import {
COCOS_EDITOR_OPERATIONS,
validateExecuteCode,
} from './cocos-editor-adapter.mjs';
import {
buildCocosOperationCode,
COCOS_EDITOR_OPERATIONS as COCOS_OPERATION_NAMES,
} from './cocos-editor-operations.mjs';
export const COCOS_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
export const COCOS_EXECUTE_COMMAND_ID = 'cocos.editor.execute';
export const COCOS_OPERATION_COMMAND_ID = 'cocos.editor.operation';
export const COCOS_CONNECTION_CAPABILITY_ID = 'cocos.editor.connection';
export const COCOS_EDITOR_PANEL = Object.freeze({
id: 'cocos-editor',
@@ -47,6 +52,7 @@ export function createCocosEditorPlugin({
const handlers = new Map([
[COCOS_EXECUTE_COMMAND_ID, handleExecute],
[COCOS_OPERATION_COMMAND_ID, handleOperation],
[COCOS_CONNECTION_CAPABILITY_ID, handleConnection],
]);
@@ -112,7 +118,11 @@ export function createCocosEditorPlugin({
};
executionPending = true;
try {
const response = await callEditor('execute', { projectPath, code });
const response = await callEditor('execute', {
projectPath,
code,
timeoutMs: params?.timeoutMs,
});
if (response?.status === 'needs-reconciliation') {
executionUncertain = true;
return reconcile(response);
@@ -146,6 +156,28 @@ export function createCocosEditorPlugin({
});
}
async function handleOperation(params) {
const operation = params?.operation;
if (!COCOS_OPERATION_NAMES.includes(operation)) {
throw new Error(`不支持的 Cocos 操作:${operation}`);
}
const projectPath = resolveProjectPath(params);
const result = await handleExecute({
projectPath,
code: buildCocosOperationCode(operation, params?.args ?? {}),
timeoutMs: 60_000,
});
const status = result?.response?.result?.status;
if (status === 'failed' || status === 'needs-reconciliation') {
result.status = status;
if (status === 'needs-reconciliation') {
executionUncertain = true;
result.retryAllowed = false;
}
}
return result;
}
async function handleMessage(message) {
if (disposed) return;
const envelope =
@@ -199,6 +231,11 @@ export function createCocosEditorPlugin({
id: COCOS_CONNECTION_CAPABILITY_ID,
description: 'Cocos Creator 连接探测、目标预检、ping、status 与注入',
});
const operations = await request('host.registerCommand', {
id: COCOS_OPERATION_COMMAND_ID,
title: '调用 Cocos Creator 编辑器能力',
description: `调用内置 Cocos 编辑器操作(${COCOS_OPERATION_NAMES.join(', ')})`,
});
const panel = await request('host.registerPanel', {
...COCOS_EDITOR_PANEL,
});
@@ -209,6 +246,7 @@ export function createCocosEditorPlugin({
return {
command,
capability,
operations,
panel,
subscriptionId: subscription?.subscriptionId ?? null,
};
+55 -2
View File
@@ -13,6 +13,7 @@ import {
COCOS_CONNECTION_CAPABILITY_ID,
COCOS_EDITOR_PANEL,
COCOS_EXECUTE_COMMAND_ID,
COCOS_OPERATION_COMMAND_ID,
COCOS_PLUGIN_PROTOCOL_VERSION,
createCocosEditorPlugin,
PROJECT_CHANGED_EVENT,
@@ -43,6 +44,8 @@ async function startPlugin(harness, projectPath = 'C:\\demo') {
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, {
subscriptionId: 'sub-1',
projectPath,
@@ -57,17 +60,67 @@ test('runtime entry registers command, capability, panel and project event', asy
assert.deepEqual(methods, [
'host.registerCommand',
'host.registerCapability',
'host.registerCommand',
'host.registerPanel',
'host.events.subscribe',
]);
assert.equal(harness.outbound[0].params.id, COCOS_EXECUTE_COMMAND_ID);
assert.equal(harness.outbound[1].params.id, COCOS_CONNECTION_CAPABILITY_ID);
assert.deepEqual(harness.outbound[2].params, { ...COCOS_EDITOR_PANEL });
assert.equal(harness.outbound[3].params.type, PROJECT_CHANGED_EVENT);
assert.equal(harness.outbound[2].params.id, COCOS_OPERATION_COMMAND_ID);
assert.deepEqual(harness.outbound[3].params, { ...COCOS_EDITOR_PANEL });
assert.equal(harness.outbound[4].params.type, PROJECT_CHANGED_EVENT);
assert.equal(result.subscriptionId, 'sub-1');
assert.equal(harness.plugin.activeProjectPath, 'C:\\demo');
});
test('operation command validates and routes a named Cocos operation', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 78,
method: COCOS_OPERATION_COMMAND_ID,
params: { operation: 'cocos_get_hierarchy', args: { maxNodes: 10 } },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.equal(rpc.method, 'host.rpc');
assert.equal(rpc.params.method, 'editor.execute');
assert.match(rpc.params.params.code, /cocos_get_hierarchy/);
assert.equal(rpc.params.params.timeoutMs, 60_000);
harness.respond(rpc.id, { ok: true });
await handling;
assert.equal(harness.outbound.at(-1).result.status, 'completed');
});
test('semantic reconciliation from an operation blocks later execute', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 178,
method: COCOS_OPERATION_COMMAND_ID,
params: { operation: 'cocos_get_hierarchy', args: {} },
});
await tick();
await harness.respond(harness.outbound.at(-1).id, {
ok: true,
result: { status: 'needs-reconciliation', rollbackError: 'restore failed' },
});
await handling;
assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation');
const count = harness.outbound.length;
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 179,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
assert.equal(harness.outbound.length, count + 1);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
harness.plugin.dispose();
});
test('execute command routes through host.rpc with the active project', async () => {
const harness = createHarness();
await startPlugin(harness);
@@ -0,0 +1,205 @@
'use strict';
module.exports = function assetOperations(Editor, require, paths) {
const fs = require('node:fs'),
path = require('node:path'),
zlib = require('node:zlib');
const request = (...args) => Editor.Message.request('asset-db', ...args);
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++)
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function shapePng(shape) {
const size = 64,
rowBytes = size * 4 + 1,
raw = Buffer.alloc(size * rowBytes);
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++) {
let coverage = 0;
for (const dx of [0.25, 0.75])
for (const dy of [0.25, 0.75]) {
const px = x + dx,
py = y + dy;
const inside =
shape === 'circle'
? Math.hypot(px - 32, py - 32) <= 31
: shape === 'rounded-rectangle'
? Math.hypot(
Math.max(12 - px, 0, px - 52),
Math.max(12 - py, 0, py - 52),
) <= 11
: true;
if (inside) coverage++;
}
const at = y * rowBytes + 1 + x * 4;
raw[at] = 255;
raw[at + 1] = 255;
raw[at + 2] = 255;
raw[at + 3] = Math.round((255 * coverage) / 4);
}
function chunk(type, body) {
const t = Buffer.from(type),
len = Buffer.alloc(4),
crc = Buffer.alloc(4);
len.writeUInt32BE(body.length);
crc.writeUInt32BE(crc32(Buffer.concat([t, body])));
return Buffer.concat([len, t, body, crc]);
}
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', header),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
async function info(relative, type) {
const url = paths.assetUrl(relative),
asset = await request('query-asset-info', url);
if (!asset || asset.invalid || asset.isDirectory)
throw new Error('资源不存在或未正确导入:' + relative);
if (
type &&
!String(asset.type || asset.importer)
.toLowerCase()
.includes(type)
)
throw new Error('资源类型不匹配:' + relative);
return asset;
}
function spriteIn(value) {
if (!value || typeof value !== 'object') return null;
if (/sprite.?frame/i.test(String(value.type || value.importer)))
return value.uuid;
for (const sub of Object.values(value.subAssets || value.subMetas || {})) {
const found = spriteIn(sub);
if (found) return found;
}
return null;
}
async function spriteFrame(relative, managed = false) {
const asset = await info(relative);
let uuid = spriteIn(asset);
if (uuid) return uuid;
const meta = await request('query-asset-meta', asset.uuid);
uuid = spriteIn(meta);
if (uuid) return uuid;
if (!managed) throw new Error('图片尚未导入为 SpriteFrame:' + relative);
if (!meta || typeof meta !== 'object')
throw new Error('无法读取模板图片导入配置');
meta.userData = { ...meta.userData, type: 'sprite-frame' };
await request('save-asset-meta', asset.uuid, JSON.stringify(meta));
await request('reimport-asset', asset.uuid);
for (let attempt = 0; attempt < 50; attempt++) {
const fresh = await request('query-asset-info', asset.uuid);
uuid =
spriteIn(fresh) ||
spriteIn(await request('query-asset-meta', asset.uuid));
if (uuid) return uuid;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('模板图片 SpriteFrame 导入未在 5 秒内完成');
}
async function shape(shape) {
if (!['circle', 'rectangle', 'rounded-rectangle'].includes(shape))
throw new Error('未知形状');
const relative = 'assets/agc-ui-shapes/' + shape + '.png',
file = paths.resolve(relative),
bytes = shapePng(shape);
await fs.promises.mkdir(path.dirname(file), { recursive: true });
paths.resolve(relative);
try {
await fs.promises.writeFile(file, bytes, { flag: 'wx' });
} catch (e) {
if (e.code !== 'EEXIST') throw e;
if (!(await fs.promises.readFile(file)).equals(bytes))
throw new Error('同名模板资产内容不符,拒绝覆盖');
}
await request('refresh-asset', 'db://assets/agc-ui-shapes');
const uuid = await spriteFrame(relative, true);
const meta = await request('query-asset-meta', paths.assetUrl(relative));
const sub = Object.values(meta.subMetas || {}).find((v) => v.uuid === uuid);
if (!sub) throw new Error('模板 SpriteFrame 元数据缺失');
const values = {
trimType: 'none',
borderTop: shape === 'rounded-rectangle' ? 12 : 0,
borderBottom: shape === 'rounded-rectangle' ? 12 : 0,
borderLeft: shape === 'rounded-rectangle' ? 12 : 0,
borderRight: shape === 'rounded-rectangle' ? 12 : 0,
};
if (Object.entries(values).some(([k, v]) => sub.userData?.[k] !== v)) {
sub.userData = { ...sub.userData, ...values };
await request(
'save-asset-meta',
paths.assetUrl(relative),
JSON.stringify(meta),
);
await request('reimport-asset', paths.assetUrl(relative));
}
return uuid;
}
async function prefab(relative) {
const asset = await info(relative, 'prefab');
const file = paths.resolve(relative),
stat = await fs.promises.stat(file);
if (stat.size > 2 * 1024 * 1024) throw new Error('Prefab 超过读取上限');
const data = JSON.parse(await fs.promises.readFile(file, 'utf8'));
return {
uuid: asset.uuid,
url: asset.url,
imported: asset.imported,
nodes: data
.filter((v) => v?.__type__ === 'cc.Node')
.map((v) => ({
name: v._name,
uuid: v._id,
childCount: v._children?.length ?? 0,
})),
componentTypes: [
...new Set(
data
.map((v) => v?.__type__)
.filter((v) => v && v !== 'cc.Node' && v !== 'cc.Prefab'),
),
],
};
}
async function list(args) {
const relative =
'assets' +
(args.relativeDir ? '/' + args.relativeDir.replace(/^assets\//, '') : '');
paths.resolve(relative);
const all = await request('query-assets', {
pattern: 'db://' + relative + '/**/*',
});
const extensions = args.extensions?.map(
(v) => '.' + v.replace(/^\./, '').toLowerCase(),
);
const filtered = (all || []).filter(
(a) =>
!a.isDirectory &&
(!extensions ||
extensions.some((ext) => a.url?.toLowerCase().endsWith(ext))),
);
return {
assets: filtered.slice(0, args.maxResults ?? 200).map((a) => ({
uuid: a.uuid,
url: a.url,
type: a.type,
importer: a.importer,
invalid: a.invalid,
})),
truncated: filtered.length > (args.maxResults ?? 200),
};
}
return { info, spriteFrame, shape, prefab, list, shapePng };
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
const sources = __AGC_SOURCES__;
const version = __AGC_VERSION__;
const schema = __AGC_SCHEMA__;
const operationNames = __AGC_NAMES__;
const key = Symbol.for('agc.cocos.operations');
let instance = globalThis[key];
if (!instance || instance.version !== version) {
if (instance) await instance.runtime.dispose();
const cache = {};
const load = (name) => {
if (!Object.hasOwn(sources, name)) throw new Error('未登记的内置模块');
if (!cache[name]) {
const module = { exports: {} };
new Function('module', 'exports', sources[name])(module, module.exports);
cache[name] = module.exports;
}
return cache[name];
};
instance = { version, runtime: load('main.cjs')(Editor, require, load, sources, version, operationNames) };
globalThis[key] = instance;
}
return await instance.runtime.execute(__AGC_OPERATION__, __AGC_ARGS__, schema);
@@ -0,0 +1,380 @@
'use strict';
module.exports = function createOperations(
Editor,
require,
load,
sources,
version,
operationNames,
) {
const { webContents } = require('electron');
const paths = load('paths.cjs')(Editor.Project.path, require);
const assets = load('assets.cjs')(Editor, require, paths);
const preview = load('preview.cjs')(require);
const validate = load('validate.cjs');
let busy = false;
function sceneView() {
const candidates = webContents
.getAllWebContents()
.filter(
(w) =>
w.getType() === 'webview' &&
w.getURL().startsWith('packages://scene/'),
);
if (candidates.length !== 1)
throw new Error('需要唯一已就绪的 Cocos 场景运行窗口');
return candidates[0];
}
async function scene(operation, args = {}) {
const expression =
'(async()=>{try{const key=Symbol.for("agc.cocos.scene.operations");' +
'if(!globalThis[key]||globalThis[key].version!==' +
JSON.stringify(version) +
'){' +
'const m={exports:{}};new Function("module","exports",' +
JSON.stringify(sources['scene.cjs']) +
')(m,m.exports);' +
'globalThis[key]={version:' +
JSON.stringify(version) +
',runtime:m.exports(require("cc"),cce,Editor)};}' +
'return await globalThis[key].runtime.call(' +
JSON.stringify(operation) +
',' +
JSON.stringify(args) +
');' +
'}catch(e){return {__sceneError:e.stack||e.message};}})()';
const result = await sceneView().executeJavaScript(expression);
if (result?.__sceneError) throw new Error(result.__sceneError);
return result;
}
async function save(relative) {
const meta = await scene('meta');
if (!meta.hasScene) throw new Error('当前没有可保存场景');
const existing = await Editor.Message.request(
'asset-db',
'query-asset-info',
meta.sceneAssetUuid,
);
if (existing) {
if (relative && paths.assetUrl(relative) !== existing.url)
throw new Error('当前场景已命名,请保存当前场景;不隐式另存为');
const uuid = await Editor.Message.request('scene', 'save-scene');
if (uuid !== existing.uuid) throw new Error('保存回执 UUID 不匹配');
const after = await scene('meta');
if (after.dirty) throw new Error('保存后仍有未提交修改');
await scene('save-receipt');
return { saved: true, uuid, url: existing.url, dirty: false };
}
relative ??= 'assets/Main.scene';
if (!relative.endsWith('.scene'))
throw new Error('场景保存路径必须以 .scene 结尾');
const url = paths.assetUrl(relative);
if (
(await Editor.Message.request('asset-db', 'query-asset-info', url)) ||
require('node:fs').existsSync(paths.resolve(relative))
)
throw new Error('目标场景已存在,拒绝覆盖');
const serialized = await scene('serialize');
const asset = await Editor.Message.request(
'asset-db',
'create-asset',
url,
serialized,
);
if (!asset?.uuid) throw new Error('AssetDB 没有创建场景');
let ready = false;
for (let attempt = 0; attempt < 50; attempt++) {
const info = await Editor.Message.request(
'asset-db',
'query-asset-info',
asset.uuid,
);
if (
info?.imported &&
!info.invalid &&
(await Editor.Message.request(
'asset-db',
'query-asset-meta',
asset.uuid,
))
) {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!ready)
throw new Error('场景文件已创建,但资源导入未完成:' + relative);
// 序列化内容已经交给 AssetDB;先标记这个快照已保存,再走官方打开入口,不篡改场景 UUID。
await scene('mark-saved');
await Editor.Message.request('scene', 'open-scene', asset.uuid);
const after = await scene('meta');
if (after.sceneAssetUuid !== asset.uuid || after.dirty)
throw new Error('首次保存后回读失败');
return { saved: true, uuid: asset.uuid, url, dirty: false };
}
function uiSpecs(operation, args) {
if (operation === 'cocos_apply_ui_spec') return structuredClone(args.nodes);
const kind = operation.slice('cocos_create_ui_'.length);
const { parentNid: _parentNid, save: _save, ...node } = args;
if (kind === 'button') {
node.buttonLabel = node.label;
delete node.label;
}
return [{ ...node, kind }];
}
function validateUi(nodes) {
let count = 0;
const ids = new Set();
const walk = (list, depth) => {
if (depth > 12) throw new Error('UI 层级超过 12 层');
for (const node of list) {
count += node.kind === 'button' ? 2 : 1;
if (count > 64)
throw new Error('UI 节点超过 64 个(含按钮文字子节点)');
if (node.id) {
if (ids.has(node.id)) throw new Error('UI id 重复');
ids.add(node.id);
}
if (node.kind === 'sprite' && !node.assetPath)
throw new Error('Sprite 必须指定 assetPath');
if (node.children) walk(node.children, depth + 1);
}
};
walk(nodes, 1);
return count;
}
async function prepareUi(nodes) {
for (const node of nodes) {
if (node.kind === 'shape' || node.kind === 'button')
node.assetUuid = await assets.shape(
node.shape ||
(node.kind === 'button' ? 'rounded-rectangle' : 'rectangle'),
);
if (node.kind === 'sprite')
node.assetUuid = await assets.spriteFrame(node.assetPath);
if (node.children) await prepareUi(node.children);
}
}
async function execute(operation, args, schema) {
validate(schema, args);
if (busy) throw new Error('已有 Cocos 操作执行中,不能积压重放');
busy = true;
try {
let result;
switch (operation) {
case 'cocos_get_capabilities':
result = {
operations: operationNames,
ui: {
maxNodes: 64,
maxDepth: 12,
persistentShapes: true,
runtimeVerification: true,
transactionUndo: true,
},
identity: 'NID 或 UUID;换场景重新查询',
undo: '只撤销当前场景与最近事务后状态一致的操作',
preview: '插件独立 Chromium 窗口,仅当前项目 loopback 地址',
};
break;
case 'cocos_ping':
result = {
ready: true,
creatorVersion: Editor.App?.version,
scene: await scene('meta'),
};
break;
case 'cocos_get_project_info': {
const pkg = JSON.parse(
await require('node:fs').promises.readFile(
paths.resolve('package.json'),
'utf8',
),
);
result = {
name:
pkg.name || require('node:path').basename(Editor.Project.path),
creator: pkg.creator,
version: Editor.App?.version,
};
break;
}
case 'cocos_get_current_scene_meta': {
result = await scene('meta');
if (!result.hasScene) {
result = { ...result, url: null, named: false };
break;
}
const asset = await Editor.Message.request(
'asset-db',
'query-asset-info',
result.sceneAssetUuid,
);
result = { ...result, url: asset?.url ?? null, named: !!asset };
break;
}
case 'cocos_get_log_tail':
result = await paths.tail(
args.relativePath ?? 'temp/logs/project.log',
args.maxLines ?? 200,
);
break;
case 'cocos_get_build_diagnostics':
result = await paths.buildLogs(
args.maxFiles ?? 10,
args.maxLinesPerFile ?? 200,
);
break;
case 'cocos_diagnose': {
let sceneResult;
try {
sceneResult = await scene('meta');
} catch (e) {
sceneResult = { error: e.message };
}
const logs = await paths.tail(
'temp/logs/project.log',
args.maxLogLines ?? 200,
);
result = {
ready: true,
scene: sceneResult,
logs,
errors: logs.lines.filter((v) => /error|exception|failed/i.test(v)),
health: sceneResult.error
? 'error'
: logs.missing
? 'unknown'
: 'connected',
};
break;
}
case 'cocos_list_assets':
result = await assets.list(args);
break;
case 'cocos_get_prefab_info':
result = await assets.prefab(args.prefabPath);
break;
case 'cocos_save_scene':
result = await save(args.path);
break;
case 'cocos_preview_debug_start':
result = await preview.start(args);
break;
case 'cocos_preview_debug_read':
result = preview.read(args);
break;
case 'cocos_preview_debug_capture':
result = await preview.capture();
break;
case 'cocos_preview_debug_stop':
result = await preview.stop();
break;
case 'cocos_mcp_undo_last': {
result = await scene(operation, args);
if (result.saved) result.persisted = await save();
return result;
}
case 'cocos_apply_ui_spec':
case 'cocos_create_ui_shape':
case 'cocos_create_ui_label':
case 'cocos_create_ui_sprite':
case 'cocos_create_ui_button': {
const nodes = uiSpecs(operation, args);
validateUi(nodes);
await scene('cocos_inspect_node', { nid: args.parentNid });
// 未命名场景先确定保存身份,避免事务完成后弹出“另存为”阻塞。
if (args.save !== false) {
const meta = await scene('meta');
if (
!(await Editor.Message.request(
'asset-db',
'query-asset-info',
meta.sceneAssetUuid,
))
)
await save();
}
await prepareUi(nodes);
const uuids = new Set();
const collect = (list) => {
for (const node of list) {
if (node.assetUuid) uuids.add(node.assetUuid);
if (node.children) collect(node.children);
}
};
collect(nodes);
await scene('preload-assets', { uuids: [...uuids] });
result = await scene('cocos_apply_ui_spec', {
parentNid: args.parentNid,
nodes,
});
if (result.status === 'completed' && args.save !== false) {
try {
result.saved = await save();
} catch (e) {
try {
await scene('rollback-last');
await save();
return {
status: 'failed',
changed: false,
verified: false,
rolledBack: true,
error: e.message,
};
} catch (rollback) {
return {
status: 'needs-reconciliation',
changed: true,
verified: false,
retryAllowed: false,
error: e.message,
rollbackError: rollback.message,
};
}
}
}
return result;
}
case 'cocos_instantiate_prefab': {
const asset = await assets.info(args.prefabPath, 'prefab');
await scene('preload-assets', { uuids: [asset.uuid] });
return await scene(operation, { ...args, assetUuid: asset.uuid });
}
case 'cocos_set_component_property': {
let value = args.value;
if (
[
'spriteFrame',
'font',
'normalSprite',
'pressedSprite',
'hoverSprite',
'disabledSprite',
].includes(args.property) &&
typeof value === 'string'
) {
const uuid = value.startsWith('assets/')
? args.property === 'font'
? (await assets.info(value)).uuid
: await assets.spriteFrame(value)
: value;
value = { assetUuid: uuid };
await scene('preload-assets', { uuids: [uuid] });
}
return await scene(operation, { ...args, value });
}
default:
result = await scene(operation, args);
}
if (result?.status) return result;
return { status: 'completed', result };
} finally {
busy = false;
}
}
return { execute, dispose: preview.stop };
};
@@ -0,0 +1,222 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { EventEmitter } = require('node:events');
const validate = require('./validate.cjs');
const catalog = require('./catalog.json');
const schema = (name) => catalog.find((t) => t.name === name).inputSchema;
test('catalog accepts nested UI and rejects invalid or ambiguous parameters', async () => {
const { buildCocosOperationCode } = await import(
'../cocos-editor-operations.mjs'
);
let nodes = [{ kind: 'label', text: '文字' }];
for (let level = 1; level < 12; level++)
nodes = [{ kind: 'container', children: nodes }];
const code = buildCocosOperationCode('cocos_apply_ui_spec', {
parentNid: 1,
nodes,
save: false,
});
assert.ok(Buffer.byteLength(code) < 128 * 1024);
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
new AsyncFunction('Editor', 'require', code);
assert.throws(
() =>
buildCocosOperationCode('cocos_delete_node', { nid: 1, confirm: false }),
/必须/,
);
assert.throws(
() =>
buildCocosOperationCode('cocos_set_node_name', {
nid: 1,
name: 'ok',
command: 'exec',
}),
/未知/,
);
assert.throws(() => buildCocosOperationCode('cocos_unknown', {}), /不支持/);
assert.throws(
() =>
validate(schema('cocos_set_node_transform'), {
nid: 1,
scale: { x: Infinity, y: 1, z: 1 },
}),
/有限/,
);
});
test('UI limits and duplicate IDs fail before touching the scene or assets', async () => {
const create = require('./main.cjs');
let touched = 0;
const load = (name) => (name === 'validate.cjs' ? validate : () => ({}));
const requireMock = (name) =>
name === 'electron'
? {
webContents: {
getAllWebContents() {
touched++;
return [];
},
},
}
: require(name);
const runtime = create(
{ Project: { path: os.tmpdir() } },
requireMock,
load,
{},
'test',
[],
);
const invoke = (nodes) =>
runtime.execute(
'cocos_apply_ui_spec',
{ parentNid: 1, nodes, save: false },
schema('cocos_apply_ui_spec'),
);
await assert.rejects(
invoke([
{ kind: 'container', id: 'same' },
{ kind: 'label', id: 'same' },
]),
/重复/,
);
await assert.rejects(
invoke(Array.from({ length: 33 }, () => ({ kind: 'button' }))),
/64/,
);
let nodes = [{ kind: 'container' }];
for (let i = 1; i < 13; i++) nodes = [{ kind: 'container', children: nodes }];
await assert.rejects(invoke(nodes), /12/);
assert.equal(touched, 0);
});
test('log reads are bounded and do not follow project escapes or junctions', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-logs-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-outside-'));
try {
fs.mkdirSync(path.join(root, 'temp/logs'), { recursive: true });
fs.writeFileSync(
path.join(root, 'temp/logs/project.log'),
'first\nerror: test\nlast',
);
const paths = require('./paths.cjs')(root, require);
assert.deepEqual((await paths.tail('temp/logs/project.log', 2)).lines, [
'error: test',
'last',
]);
assert.equal((await paths.tail('temp/logs/missing.log')).missing, true);
await assert.rejects(paths.tail('../secret.log'), /只读取/);
assert.throws(() => paths.resolve('assets/../../outside'), /路径/);
fs.symlinkSync(
outside,
path.join(root, 'assets'),
process.platform === 'win32' ? 'junction' : 'dir',
);
assert.throws(() => paths.resolve('assets/leak.png'), /链接|junction/);
} finally {
// 测试自己创建的两个明确临时根;先移除链接,递归清理不跟随外部目录。
if (fs.existsSync(path.join(root, 'assets')))
fs.unlinkSync(path.join(root, 'assets'));
for (const target of [root, outside]) {
assert.equal(path.dirname(target), os.tmpdir());
fs.rmSync(target, { recursive: true, force: true });
}
}
});
function previewHarness({ stall = false } = {}) {
const windows = [];
class Window {
constructor() {
this.destroyed = false;
this.loaded = false;
this.calls = [];
const debuggerApi = new EventEmitter();
debuggerApi.attach = () => this.calls.push('attach');
debuggerApi.detach = () => this.calls.push('detach');
debuggerApi.sendCommand = async (command) => {
assert.equal(this.loaded, true, 'renderer must exist before CDP');
this.calls.push(command);
};
this.webContents = new EventEmitter();
this.webContents.debugger = debuggerApi;
this.webContents.setWindowOpenHandler = (fn) => (this.windowOpen = fn);
this.webContents.capturePage = async () => ({
isEmpty: () => false,
resize: () => ({
getSize: () => ({ width: 100, height: 60 }),
toPNG: () => Buffer.from('png'),
}),
getSize: () => ({ width: 100, height: 60 }),
});
windows.push(this);
}
async loadURL(url) {
this.calls.push(url);
if (stall) return new Promise(() => {});
this.loaded = true;
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
const electron = {
BrowserWindow: Window,
webContents: {
getAllWebContents: () => [
{
getType: () => 'webview',
getURL: () => 'http://localhost:7456/preview',
},
],
},
};
return {
windows,
runtime: require('./preview.cjs')((name) =>
name === 'electron' ? electron : require(name),
),
};
}
test('managed preview captures errors and only closes its own window', async () => {
const { runtime, windows } = previewHarness();
await runtime.start({ url: 'http://localhost:7456/', timeoutMs: 100 });
const window = windows[0];
assert.equal(window.calls[0], 'about:blank');
assert.equal(window.windowOpen().action, 'deny');
window.webContents.debugger.emit('message', {}, 'Runtime.exceptionThrown', {
exceptionDetails: { text: 'script error' },
});
window.webContents.debugger.emit('message', {}, 'Network.loadingFailed', {
errorText: 'network failed',
});
assert.equal(runtime.read({ errorsOnly: true }).events.length, 2);
assert.equal((await runtime.capture()).__image.mimeType, 'image/png');
await runtime.stop();
assert.equal(window.destroyed, true);
assert.throws(() => runtime.read({}), /没有/);
await assert.rejects(
runtime.start({ url: 'http://localhost:9999/' }),
/当前 Creator/,
);
await assert.rejects(
runtime.start({ url: 'https://example.com' }),
/loopback/,
);
});
test('preview initialization timeout tears down even before renderer is ready', async () => {
const { runtime, windows } = previewHarness({ stall: true });
await assert.rejects(
runtime.start({ url: 'http://localhost:7456/', timeoutMs: 10 }),
/超时/,
);
assert.equal(windows[0].destroyed, true);
});
@@ -0,0 +1,139 @@
'use strict';
module.exports = function projectPaths(root, require) {
const fs = require('node:fs');
const path = require('node:path');
root = fs.realpathSync(root);
function resolve(relative) {
if (
typeof relative !== 'string' ||
!relative ||
relative.includes('\0') ||
path.isAbsolute(relative) ||
relative.includes(':')
)
throw new Error('必须使用项目相对路径');
const parts = relative.replaceAll('\\', '/').split('/');
if (
parts.some((p) => p === '..' || !p) ||
['.agent', '.git', '.codex', '.env'].some((p) => parts[0] === p)
)
throw new Error('路径超出允许范围');
const target = path.resolve(root, ...parts);
const rel = path.relative(root, target);
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel))
throw new Error('路径不属于项目');
let cursor = root;
for (const part of parts) {
cursor = path.join(cursor, part);
try {
if (fs.lstatSync(cursor).isSymbolicLink())
throw new Error('不允许符号链接或 junction');
} catch (e) {
if (e.code === 'ENOENT') break;
throw e;
}
}
return target;
}
function assetUrl(relative) {
if (!relative?.startsWith('assets/'))
throw new Error('资源路径必须位于 assets/');
resolve(relative);
return 'db://' + relative;
}
async function tail(relative, maxLines = 200) {
if (
!/^(temp\/logs\/|temp\/builder\/|build\/)/.test(relative) ||
!/\.(log|txt)$/i.test(relative)
)
throw new Error('只读取 temp/logs、temp/builder 或 build 下日志');
const file = resolve(relative);
let handle;
try {
handle = await fs.promises.open(file, 'r');
} catch (e) {
if (e.code === 'ENOENT')
return { path: relative, missing: true, lines: [] };
throw e;
}
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new Error('日志不是普通文件');
const size = Math.min(stat.size, 256 * 1024);
const buffer = Buffer.alloc(size);
await handle.read(buffer, 0, size, stat.size - size);
const lines = buffer.toString('utf8').split(/\r?\n/);
if (stat.size > size) lines.shift();
return {
path: relative,
missing: false,
truncated: stat.size > size || lines.length > maxLines,
lines: lines.slice(-maxLines),
};
} finally {
await handle.close();
}
}
async function buildLogs(maxFiles, maxLines) {
const files = [];
let visited = 0;
async function scan(relative, depth = 0) {
if (depth > 5 || visited >= 3000) return;
let entries;
try {
entries = await fs.promises.readdir(resolve(relative), {
withFileTypes: true,
});
} catch (e) {
if (e.code === 'ENOENT') return;
throw e;
}
for (const entry of entries) {
if (++visited > 3000) break;
if (entry.isSymbolicLink()) continue;
const next = relative + '/' + entry.name;
if (entry.isDirectory()) await scan(next, depth + 1);
else if (entry.isFile() && /\.(log|txt)$/i.test(entry.name)) {
const stat = await fs.promises.stat(resolve(next));
files.push({ path: next, mtime: stat.mtimeMs });
}
}
}
await scan('temp/logs');
await scan('temp/builder');
await scan('build');
files.sort((a, b) => b.mtime - a.mtime);
const logs = [];
let remaining = 384 * 1024;
for (const file of files.slice(0, maxFiles)) {
if (remaining <= 0) break;
const log = await tail(file.path, maxLines);
const selected = [];
for (const line of log.lines) {
const bytes = Buffer.byteLength(line);
if (bytes > remaining) {
log.truncated = true;
break;
}
remaining -= bytes;
selected.push(line);
}
log.lines = selected;
logs.push(log);
}
return {
files: logs,
scannedEntries: visited,
truncated: visited >= 3000 || files.length > maxFiles,
diagnostics: logs
.flatMap((f) =>
f.lines
.filter((l) => /error|exception|failed|warning/i.test(l))
.map((message) => ({ path: f.path, message })),
)
.slice(0, 500),
};
}
return { resolve, assetUrl, tail, buildLogs };
};
@@ -0,0 +1,182 @@
'use strict';
module.exports = function previewOperations(require) {
const { BrowserWindow, webContents } = require('electron');
let current = null;
function eventsPush(event) {
if (!current) return;
current.events.push({ at: new Date().toISOString(), ...event });
if (current.events.length > 1000) current.events.shift();
}
function active() {
if (!current || current.window.isDestroyed())
throw new Error('没有插件托管的预览,请先调用 cocos_preview_debug_start');
return current;
}
function checkUrl(url) {
const u = new URL(url);
if (
!['http:', 'https:'].includes(u.protocol) ||
!['localhost', '127.0.0.1', '[::1]'].includes(u.hostname) ||
u.username ||
u.password
)
throw new Error('预览必须是当前项目的 loopback HTTP 地址');
const candidates = webContents
.getAllWebContents()
.filter((w) => w.getType() === 'webview')
.map((w) => w.getURL());
const matches = candidates.some((candidate) => {
try {
const c = new URL(candidate);
return (
c.protocol === u.protocol &&
c.port === u.port &&
['localhost', '127.0.0.1', '[::1]'].includes(c.hostname)
);
} catch {
return false;
}
});
if (!matches)
throw new Error('地址端口不属于当前 Creator 已加载的项目预览');
return u;
}
async function stop() {
if (!current) return { stopped: false };
const item = current;
current = null;
if (!item.window.isDestroyed()) {
try {
item.window.webContents.debugger.detach();
} catch {
// 窗口可能已经自行断开调试连接,仍继续关闭自有窗口。
}
item.window.destroy();
}
return { stopped: true, eventCount: item.events.length };
}
async function start(args) {
const url = checkUrl(args.url);
await stop();
const window = new BrowserWindow({
width: 1100,
height: 760,
title: 'Cocos Preview · AGC',
show: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
partition: 'agc-cocos-preview-' + Date.now(),
},
});
current = { window, events: [], url: url.href };
const wc = window.webContents;
const allowed = (target) => {
try {
const u = new URL(target);
return u.origin === url.origin;
} catch {
return false;
}
};
wc.setWindowOpenHandler(() => ({ action: 'deny' }));
wc.on('will-navigate', (event, target) => {
if (!allowed(target)) event.preventDefault();
});
wc.on('will-redirect', (event, target) => {
if (!allowed(target)) event.preventDefault();
});
wc.on('console-message', (_event, level, message, line, sourceId) =>
eventsPush({ kind: 'console', level, message, line, sourceId }),
);
wc.on('did-fail-load', (_event, code, message, failedUrl) =>
eventsPush({
kind: 'load-error',
level: 3,
code,
message,
url: failedUrl,
}),
);
wc.on('render-process-gone', (_event, details) =>
eventsPush({ kind: 'renderer-error', level: 3, message: details.reason }),
);
let timer;
try {
const initialize = async () => {
// 新 BrowserWindow 尚无 renderer target;先创建 about:blank 文档再启用 CDP。
await window.loadURL('about:blank');
wc.debugger.attach('1.3');
wc.debugger.on('message', (_event, method, params) => {
if (method === 'Runtime.exceptionThrown')
eventsPush({
kind: 'exception',
level: 3,
message:
params.exceptionDetails?.exception?.description ||
params.exceptionDetails?.text,
details: params.exceptionDetails,
});
if (method === 'Network.loadingFailed')
eventsPush({ kind: 'network-error', level: 3, ...params });
if (
method === 'Network.responseReceived' &&
params.response?.status >= 400
)
eventsPush({
kind: 'http-error',
level: 3,
url: params.response.url,
status: params.response.status,
});
});
await wc.debugger.sendCommand('Runtime.enable');
await wc.debugger.sendCommand('Network.enable');
await window.loadURL(url.href);
};
await Promise.race([
initialize(),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('预览初始化或加载超时')),
args.timeoutMs ?? 15000,
);
}),
]);
} catch (error) {
await stop();
throw error;
} finally {
clearTimeout(timer);
}
return { started: true, url: url.href, browser: 'embedded-chromium' };
}
function read(args) {
const item = active();
const events = item.events.filter((e) => !args.errorsOnly || e.level >= 2);
return {
url: item.url,
events: events.slice(-(args.maxEvents ?? 100)),
truncated: events.length > (args.maxEvents ?? 100),
};
}
async function capture() {
const item = active(),
image = await item.window.webContents.capturePage();
if (image.isEmpty()) throw new Error('预览截图为空');
const small = image.resize({
width: Math.min(1024, image.getSize().width),
});
const data = small.toPNG().toString('base64');
if (data.length > 1500000) throw new Error('预览截图超过回执大小上限');
return {
url: item.url,
width: small.getSize().width,
height: small.getSize().height,
__image: { data, mimeType: 'image/png' },
};
}
return { start, read, capture, stop };
};
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More