补齐并公开客户端 Cocos 编辑器操作能力
新增统一目录和 36 个 Cocos 场景、组件、UI、资源与预览操作 复用内置桥接并接通 DirectProject MCP 工具发现和调用 补充事务回滚、撤销、首次保存与预览错误采集 增加真实 Creator smoke、构造器一致性测试和使用文档
This commit is contained in:
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,需要真实集成阶段提供运行时中断方案。
|
||||
|
||||
|
||||
@@ -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__"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user