接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s

客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #338.
This commit is contained in:
2026-09-13 14:48:55 +08:00
committed by 段舒康
parent b12a81e9c2
commit 4a46f89c9b
117 changed files with 15158 additions and 320 deletions
+70
View File
@@ -0,0 +1,70 @@
# AGC 插件工作区
本目录是 AGC 插件的工作区,每个一级子目录是一个 **Agent Plugins 包**,随 AGC 客户端分发。
```text
plugins/
└─ agc-cocos-editor/
├─ plugin.json Agent Plugins 标准清单 + AGC Runtime 扩展
├─ package.json npm workspace 成员,声明 SDK 版本契约
├─ panels/ 自包含面板 HTML
├─ src/ 运行时入口与适配器翻译
└─ native/ 插件自带 native 模块(Cargo 包)
```
## 清单格式
`plugin.json` 使用 OpenAI Agent Plugins 标准 schema,AGC 专属字段放在
`extensions.world.genarrative.agc`:
```json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "agc-cocos-editor",
"version": "0.1.0",
"extensions": {
"com.openai": {
"interface": { "displayName": "Cocos Creator 编辑器桥接" }
},
"world.genarrative.agc": {
"apiVersion": "v1",
"entry": "./src/entry.mjs",
"adapter": "cocos-editor",
"permissions": [
"events.subscribe",
"editor.rpc",
"ui.register",
"capability.register"
],
"panels": []
}
}
}
```
约束与通用宿主一致:插件目录内最多一个 Plugin;`entry` 必须是包内普通文件;
未知权限、非法入口或不支持的 `apiVersion` 会让插件进入 `invalid` 状态,不启动进程。
## 宿主如何加载
宿主按以下顺序解析插件工作区,任一命中即生效:
1. 环境变量 `AGC_PLUGIN_WORKSPACE`(本地联调与测试)。
2. 随包资源目录 `<resource_dir>/plugins`(安装包)。
3. 开发构建的仓库 `plugins/` 目录。
工作区插件是**内置插件**:随客户端分发、不能卸载,只能通过可用开关控制是否生效。
开关状态保存在 AppData `extensions/builtin-plugins.json`,内置插件优先级高于同名
导入插件,不会被 AppData 覆盖或删除。插件运行入口由宿主以插件目录为 cwd 启动
(`.mjs` 用系统 `node`,其它入口直接执行),stdio 上使用一行一个 JSON-RPC 2.0 消息。
## 新增插件
1. 新建 `plugins/<kebab-case-name>/`,写 `plugin.json`,`name` 与目录名保持一致。
2. 运行时插件提供 `src/entry.mjs`,按 `agc.plugin.v1` 协议注册命令、面板和能力;
仅打包 Skill/MCP 的插件可以没有 `entry`,宿主会按 `package` 状态展示。
3. 需要编辑器原生能力时,在 `native/` 下放插件自己的 Cargo 包,并实现
`editor-adapter-api` 的 `EditorAdapter`;`plugin.json` 的 `adapter` 字段必须与
`EditorAdapter::id()` 一致。
4. 在根 `package.json`、`scripts/check-npm-workspaces.mjs` 登记 workspace 成员。
5. 更新 `docs/technical/` 里的插件或编辑器方案文档。
+140
View File
@@ -0,0 +1,140 @@
# agc-cocos-editor
Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插件:插件生命周期、UI、
RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。
它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。用户在运行时设置里只能切换
“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 `cocos_*` Agent 工具也会
从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。
Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于
AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 `agc-cocos-editor`
内置插件;内置工具不可用时报告客户端插件状态,不扫描或要求用户启动项目内 MCP 扩展。
```text
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 协议、注入)
```
## Runtime 契约
| 项 | 值 |
| --------- | ---------------------------------------------------------------------------------- |
| 协议 | `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) |
`operation` 取值为 `detect`、`connect`、`disconnect`、`prepare`、`ping`、`status`、
`execute`、`inject`,与 native 适配器的 `COCOS_EDITOR_RPC_METHODS` 一一对应;
`src/entry.test.mjs` 会校验两边不会漂移。
## Agent 能力与 JavaScript 用法
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);` |
| 修改属性 | 优先 `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);` |
先执行只读查询取得真实节点 UUID、组件标识和属性路径,再执行单个修改并回读校验。
不要猜 UUID、组件 CID 或属性路径;需要保存、删除或批量修改时,先确认对应 Creator
消息协议,再通过同一入口逐步执行。
execute 不接受并发积压。结果不确定时返回 `needs-reconciliation` 与
`retryAllowed: false` 并阻止后续发送;native 适配器的阻断不会被 disconnect
或插件进程重载清除。请先核对编辑器状态,再重启客户端恢复。
## 项目上下文
插件从宿主获得当前受控项目路径:
- 注册 `host.events.subscribe { type: 'project.changed' }` 时,宿主在响应里返回当前
`projectPath`。
- 之后宿主设置项目时推送 `project.changed` 事件,payload 带 `projectPath`。
插件不缓存凭据;资源与日志只在当前受控项目内有界读取。编辑器操作经 `host.rpc` 交给 native 适配器,
由适配器做 PID / 项目 / 版本校验。
## Native 模块
`native/cocos-editor-bridge` 是从 AGC 服务端源码树移入本插件包的独立 crate,实现
`editor-adapter-api::EditorAdapter`:
- `process-discovery`:只把 `CocosCreator.exe` 主进程的 PID、`--project` 和 Creator
版本绑定起来,排除 Electron 子进程。
- `windows-transport`:注入后通过 named pipe 提供 `ping/status/execute`,执行结果
不确定时返回 `ExecutionUncertain` 并禁止自动重放。
- `windows-injection`:随包 DLL 的 Windows 注入实现,默认关闭。
- `windows-bootstrap`:在首次 connect/execute 前经目标 PID 的 Node Inspector
安装内置 bootstrap,验证 pipe 握手并关闭本次开启的 Inspector;保留已有调试会话。
AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通用宿主按 manifest 的
`adapter` 字段注册;宿主源码里没有 Cocos 进程名、注入或 Editor.Message 逻辑。
## 本地验证
```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
npm test --prefix plugins/agc-cocos-editor
```
真实 Creator 验收(注入、Inspector 引导、非空场景读写)仍按
`docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md` 单独执行。
@@ -0,0 +1,2 @@
/target/
/Cargo.lock
@@ -0,0 +1,58 @@
[package]
name = "cocos-editor-bridge"
version = "0.1.0"
edition = "2021"
license = "UNLICENSED"
publish = false
build = "build.rs"
description = "可选的 Cocos Creator 编辑器进程发现与 Windows bridge 注入核心"
[lib]
crate-type = ["rlib", "cdylib"]
[features]
# The crate is intentionally inert unless a consumer opts into one of the
# integration layers. This keeps server and non-desktop builds free of
# process access and Windows injection code.
default = []
process-discovery = []
windows-transport = [
"process-discovery",
"dep:windows-sys",
"windows-sys/Win32_Foundation",
"windows-sys/Win32_Security",
"windows-sys/Win32_Storage_FileSystem",
"windows-sys/Win32_System_IO",
"windows-sys/Win32_System_Pipes",
"windows-sys/Win32_System_Threading",
]
windows-injection = [
"windows-transport",
"windows-sys/Win32_System_Diagnostics_Debug",
"windows-sys/Win32_System_LibraryLoader",
"windows-sys/Win32_System_Memory",
]
windows-bootstrap = [
"windows-transport",
"dep:reqwest",
"dep:tungstenite",
"windows-sys/Win32_NetworkManagement_IpHelper",
"windows-sys/Win32_Networking_WinSock",
"windows-sys/Win32_System_Memory",
"windows-sys/Win32_System_ProcessStatus",
"windows-sys/Win32_System_SystemInformation",
]
[dependencies]
editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", optional = true, default-features = false }
reqwest = { version = "0.12", optional = true, default-features = false, features = ["blocking", "json"] }
tungstenite = { version = "0.28", optional = true, default-features = false, features = ["handshake"] }
[build-dependencies]
cc = "1"
@@ -0,0 +1,15 @@
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()
{
println!("cargo:rustc-link-lib=user32");
cc::Build::new()
.cpp(true)
.file("native/native_payload.cpp")
.flag_if_supported("/std:c++17")
.compile("cocos_editor_bridge_native");
}
}
@@ -0,0 +1,34 @@
//! 对用户指定的已打开 Creator 项目执行只读冷启动/复用验证,不写项目文件。
use cocos_editor_bridge::{
execute_cocos_editor_code_for_project, ping_cocos_editor, CocosEditorAdapter,
};
use editor_adapter_api::EditorAdapter;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let project = std::env::args()
.nth(1)
.ok_or("需要显式提供已打开的 Cocos 项目目录")?;
let mut adapter = CocosEditorAdapter::default();
let target = adapter.detect(Path::new(&project))?;
let pid = target.pid.ok_or("没有匹配的 Creator")?;
println!("detected={}", serde_json::to_string(&target)?);
println!("before={:?}", ping_cocos_editor(pid, &project, 40));
for phase in ["first", "warm"] {
let response = execute_cocos_editor_code_for_project(&project,
"return {pid:process.pid,version:Editor.App.version,project:Editor.Project.path,inspectorOpen:!!require('node:inspector').url()};", 3000)?;
if !response.ok {
return Err(format!("{phase}: {:?}", response.error).into());
}
println!("{phase}={}", serde_json::to_string(&response)?);
}
let connected = adapter.connect(
pid,
Path::new(&project),
target.version.as_deref().unwrap_or(""),
)?;
assert!(connected.connected);
assert!(ping_cocos_editor(pid, &project, 500)?.ok);
println!("connected={}", serde_json::to_string(&connected)?);
Ok(())
}
@@ -0,0 +1,171 @@
// Cocos Creator 3.8.x native payload.
//
// This file intentionally uses dynamic symbol lookup instead of linking to
// Electron. Creator ships a private Electron build and has no stable import
// library. The symbols below are the exported V8/Node ABI of Electron 31.
// The payload is loaded only after the Rust side has validated the exact
// Creator PID and project identity.
#include <windows.h>
#include <cstring>
#include <string>
extern "C" const char* cocos_editor_bridge_bootstrap_source();
extern "C" void cocos_editor_bridge_native_anchor() {}
namespace {
using Isolate = void;
using Local = void*;
using Environment = void;
using MaybeLocal = void*;
template <typename T> T symbol(const char* name) {
return reinterpret_cast<T>(GetProcAddress(GetModuleHandleW(nullptr), name));
}
using GetCurrentIsolate = Isolate* (*)();
using GetCurrentContext = Local (*)(Isolate*);
using GetCurrentEnvironment = Environment* (*)(Local);
using GetMainContext = Local (*)(Environment*);
using HandleScopeCtor = void (*)(void*, Isolate*);
using HandleScopeDtor = void (*)(void*);
using ContextEnter = void (*)(Local);
using ContextExit = void (*)(Local);
using NewString = MaybeLocal (*)(Isolate*, const char*, int, int);
using CompileScript = MaybeLocal (*)(Local, Local, void*);
using RunScript = MaybeLocal (*)(void*, Local);
using RequestInterrupt = void (*)(Isolate*, void (*)(Isolate*, void*), void*);
constexpr const char* kGetCurrentIsolate = "?TryGetCurrent@Isolate@v8@@SAPEAV12@XZ";
constexpr const char* kGetCurrentContext = "?GetCurrentContext@Isolate@v8@@QEAA?AV?$Local@VContext@v8@@@2@XZ";
constexpr const char* kGetCurrentEnvironment = "?GetCurrentEnvironment@node@@YAPEAVEnvironment@1@V?$Local@VContext@v8@@@v8@@@Z";
constexpr const char* kGetMainContext = "?GetMainContext@node@@YA?AV?$Local@VContext@v8@@@v8@@PEAVEnvironment@1@@Z";
constexpr const char* kHandleScopeCtor = "??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z";
constexpr const char* kHandleScopeDtor = "??1HandleScope@v8@@QEAA@XZ";
constexpr const char* kContextEnter = "?Enter@Context@v8@@QEAAXXZ";
constexpr const char* kContextExit = "?Exit@Context@v8@@QEAAXXZ";
constexpr const char* kNewString = "?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z";
constexpr const char* kCompileScript = "?Compile@Script@v8@@SA?AV?$MaybeLocal@VScript@v8@@@2@V?$Local@VContext@v8@@@2@V?$Local@VString@v8@@@2@PEAVScriptOrigin@2@@Z";
constexpr const char* kRunScript = "?Run@Script@v8@@QEAA?AV?$MaybeLocal@VValue@v8@@@2@V?$Local@VContext@v8@@@2@@Z";
constexpr const char* kRequestInterrupt = "?RequestInterrupt@Isolate@v8@@QEAAXP6AXPEAV12@PEAX@Z1@Z";
HHOOK g_hook = nullptr;
HMODULE g_instance = nullptr;
DWORD g_ui_thread = 0;
HWND g_window = nullptr;
volatile LONG g_attempted = 0;
void interrupt_bootstrap(Isolate*, void*);
void trace(const char* message) {
OutputDebugStringA(message);
OutputDebugStringA("\n");
}
BOOL CALLBACK find_window(HWND hwnd, LPARAM) {
DWORD pid = 0;
DWORD thread = GetWindowThreadProcessId(hwnd, &pid);
if (pid == GetCurrentProcessId() && thread != 0 && IsWindowVisible(hwnd)) {
g_window = hwnd;
g_ui_thread = thread;
return FALSE;
}
return TRUE;
}
bool run_bootstrap() {
auto current = symbol<GetCurrentIsolate>(kGetCurrentIsolate);
auto get_context = symbol<GetCurrentContext>(kGetCurrentContext);
auto get_env = symbol<GetCurrentEnvironment>(kGetCurrentEnvironment);
auto get_main_context = symbol<GetMainContext>(kGetMainContext);
auto scope_ctor = symbol<HandleScopeCtor>(kHandleScopeCtor);
auto scope_dtor = symbol<HandleScopeDtor>(kHandleScopeDtor);
auto enter = symbol<ContextEnter>(kContextEnter);
auto exit = symbol<ContextExit>(kContextExit);
auto new_string = symbol<NewString>(kNewString);
auto compile = symbol<CompileScript>(kCompileScript);
auto run = symbol<RunScript>(kRunScript);
if (!current || !get_context || !get_env || !get_main_context || !scope_ctor ||
!scope_dtor || !enter || !exit || !new_string || !compile || !run) {
trace("missing-v8-symbol");
return false;
}
Isolate* isolate = current();
if (!isolate) { trace("no-current-isolate"); return false; }
Local context = get_context(isolate);
Environment* env = context ? get_env(context) : nullptr;
if (!env) { trace("no-current-environment"); return false; }
Local main_context = get_main_context(env);
if (!main_context) { trace("no-main-context"); return false; }
alignas(16) unsigned char handle_scope[64] = {};
scope_ctor(handle_scope, isolate);
enter(main_context);
bool ok = false;
do {
const char* source = cocos_editor_bridge_bootstrap_source();
if (!source) { trace("no-bootstrap-source"); break; }
std::string script;
script.reserve(strlen(source) + 256);
script += "(function(){const module={exports:{}};const exports=module.exports;const require=globalThis.require;const Editor=globalThis.Editor;";
script += source;
script += ";return module.exports.install(Editor);})()";
Local source_string = reinterpret_cast<Local>(new_string(isolate, script.c_str(), 0, static_cast<int>(script.size())));
if (!source_string) { trace("new-string-failed"); break; }
Local compiled = reinterpret_cast<Local>(compile(main_context, source_string, nullptr));
if (!compiled) { trace("compile-failed"); break; }
Local result = reinterpret_cast<Local>(run(compiled, main_context));
ok = result != nullptr;
trace(ok ? "bootstrap-ok" : "run-failed");
} while (false);
exit(main_context);
scope_dtor(handle_scope);
return ok;
}
void interrupt_bootstrap(Isolate*, void*) {
if (run_bootstrap()) {
InterlockedExchange(&g_attempted, 2);
if (g_hook) {
UnhookWindowsHookEx(g_hook);
g_hook = nullptr;
}
} else {
InterlockedExchange(&g_attempted, 0);
}
}
LRESULT CALLBACK call_window_proc(int code, WPARAM wparam, LPARAM lparam) {
if (code >= 0 && !InterlockedCompareExchange(&g_attempted, 1, 0)) {
auto current = symbol<GetCurrentIsolate>(kGetCurrentIsolate);
auto request_interrupt = symbol<RequestInterrupt>(kRequestInterrupt);
Isolate* isolate = current ? current() : nullptr;
if (isolate && request_interrupt) {
trace("request-interrupt");
request_interrupt(isolate, interrupt_bootstrap, nullptr);
} else {
InterlockedExchange(&g_attempted, 0);
}
}
return CallNextHookEx(g_hook, code, wparam, lparam);
}
DWORD WINAPI bootstrap_thread(void*) {
trace("bootstrap-thread");
for (int i = 0; i < 120 && !g_window; ++i) {
EnumWindows(find_window, 0);
if (!g_window) Sleep(100);
}
if (!g_ui_thread) { trace("no-ui-thread"); return 0; }
g_hook = SetWindowsHookExW(WH_CALLWNDPROC, call_window_proc, g_instance, g_ui_thread);
if (!g_hook) { trace("hook-failed"); return 0; }
trace("hook-installed");
if (g_hook && g_window) PostMessageW(g_window, WM_NULL, 0, 0);
return 0;
}
} // namespace
extern "C" void cocos_editor_bridge_native_process_attach(HMODULE instance) {
g_instance = instance;
DisableThreadLibraryCalls(instance);
HANDLE thread = CreateThread(nullptr, 0, bootstrap_thread, nullptr, 0, nullptr);
if (thread) CloseHandle(thread);
}
@@ -0,0 +1,228 @@
'use strict';
// This module is evaluated in the Creator main-process Node context by the
// native bootstrap. It is not a Creator extension and writes no project files.
const net = require('node:net');
const fs = require('node:fs');
const path = require('node:path');
const SCHEMA_VERSION = 'game-creator-cocos-editor-bridge.v1';
const MAX_CODE_BYTES = 128 * 1024;
const MAX_FRAME_BYTES = MAX_CODE_BYTES * 6 + 16 * 1024;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const INSTANCE_KEY = Symbol.for('genarrative.cocos-editor-bridge.v1');
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
function normalizeProjectPath(value) {
if (typeof value !== 'string' || !path.isAbsolute(value)) {
throw new Error('projectPath must be absolute');
}
// Rust canonicalize 的扩展路径必须先转回 Node 可解析的盘符/UNC 形式。
const ordinary = value
.replace(/^\\\\\?\\UNC\\/i, '\\\\')
.replace(/^\\\\\?\\/, '');
const canonical = fs.realpathSync(ordinary);
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
}
function validateEnvelope(request, projectPath) {
if (!request || typeof request !== 'object' || Array.isArray(request)) {
throw new Error('request must be an object');
}
const keys = Object.keys(request).sort().join(',');
if (keys !== 'command,processId,projectPath,requestId,schemaVersion') {
throw new Error('unknown or missing request fields');
}
if (
request.schemaVersion !== SCHEMA_VERSION ||
request.processId !== process.pid
) {
throw new Error('bridge identity mismatch');
}
if (
typeof request.requestId !== 'string' ||
!request.requestId ||
request.requestId.length > 128
) {
throw new Error('invalid requestId');
}
if (normalizeProjectPath(request.projectPath) !== projectPath) {
throw new Error('project identity mismatch');
}
const command = request.command;
if (!command || typeof command !== 'object' || Array.isArray(command)) {
throw new Error('command must be an object');
}
if (command.op === 'ping' || command.op === 'status') {
if (Object.keys(command).length !== 1)
throw new Error('unknown command fields');
} else if (command.op === 'execute') {
if (Object.keys(command).sort().join(',') !== 'code,op')
throw new Error('unknown execute fields');
if (
typeof command.code !== 'string' ||
!command.code.trim() ||
command.code.includes('\0')
) {
throw new Error('execute.code must be non-empty text without NUL');
}
if (Buffer.byteLength(command.code, 'utf8') > MAX_CODE_BYTES)
throw new Error('execute.code exceeds limit');
} else {
throw new Error('unsupported command');
}
}
function install(Editor, options = {}) {
if (
!Editor?.Project?.path ||
typeof Editor?.Message?.request !== 'function'
) {
throw new Error('bootstrap requires the Creator main-process Editor API');
}
const projectPath = normalizeProjectPath(Editor.Project.path);
const existing = globalThis[INSTANCE_KEY];
if (existing) {
if (existing.projectPath !== projectPath)
throw new Error('bridge is bound to another project');
return existing;
}
const endpoint =
options.endpoint ?? `\\\\.\\pipe\\genarrative-cocos-editor-${process.pid}`;
let busy = false;
let pendingExecutions = 0;
let queue = Promise.resolve();
const sockets = new Set();
const response = (request, ok, result, error) => ({
schemaVersion: SCHEMA_VERSION,
requestId: typeof request?.requestId === 'string' ? request.requestId : '',
processId: process.pid,
ok,
...(ok
? { result: result === undefined ? null : result }
: { error: String(error) }),
});
function send(socket, value) {
if (socket.destroyed) return;
let payload;
try {
payload = JSON.stringify(value);
if (Buffer.byteLength(payload, 'utf8') + 1 > MAX_RESPONSE_BYTES)
throw new Error('result exceeds limit');
} catch (error) {
payload = JSON.stringify(
response(
value,
false,
null,
`result is not serializable: ${error.message}`,
),
);
}
socket.end(`${payload}\n`);
}
async function dispatch(socket, request) {
try {
validateEnvelope(request, projectPath);
if (normalizeProjectPath(Editor.Project.path) !== projectPath)
throw new Error('Creator project changed');
if (request.command.op === 'ping') {
send(socket, response(request, true, { ready: true }));
} else if (request.command.op === 'status') {
send(
socket,
response(request, true, {
ready: true,
busy,
creatorVersion:
typeof Editor.App?.version === 'string'
? Editor.App.version
: null,
}),
);
} else {
if (pendingExecutions >= 8) throw new Error('execute queue is full');
pendingExecutions += 1;
// Serialize mutations. A client timeout does not cancel JavaScript or
// permit replay: the next execution waits until this one has settled.
queue = queue.then(async () => {
busy = true;
try {
if (normalizeProjectPath(Editor.Project.path) !== projectPath)
throw new Error('Creator project changed');
const execute = new AsyncFunction(
'Editor',
'require',
`"use strict";\n${request.command.code}`,
);
const result = await execute(Editor, require);
send(socket, response(request, true, result));
} catch (error) {
send(socket, response(request, false, null, error?.stack ?? error));
} finally {
busy = false;
pendingExecutions -= 1;
}
});
await queue;
}
} catch (error) {
send(socket, response(request, false, null, error?.message ?? error));
}
}
const server = net.createServer((socket) => {
sockets.add(socket);
let buffer = Buffer.alloc(0);
let received = false;
socket.on('error', () => {});
socket.on('close', () => sockets.delete(socket));
socket.on('data', (chunk) => {
if (received) return;
if (buffer.length + chunk.length > MAX_FRAME_BYTES) {
received = true;
socket.destroy();
return;
}
buffer = Buffer.concat([buffer, chunk]);
const newline = buffer.indexOf(10);
if (newline === -1) return;
received = true;
let request;
try {
request = JSON.parse(buffer.subarray(0, newline).toString('utf8'));
} catch {
socket.destroy();
return;
}
void dispatch(socket, request);
});
});
server.maxConnections = 8;
const ready = new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(endpoint, () => {
server.removeListener('error', reject);
resolve();
});
});
// A duplicate bootstrap uses the same instance rather than rebinding or
// creating a second queue. Disposal is a native-loader lifecycle API.
const instance = {
projectPath,
ready,
async dispose() {
await queue;
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
if (globalThis[INSTANCE_KEY] === instance)
delete globalThis[INSTANCE_KEY];
},
};
globalThis[INSTANCE_KEY] = instance;
ready.catch(() => {
if (globalThis[INSTANCE_KEY] === instance) delete globalThis[INSTANCE_KEY];
});
return instance;
}
module.exports = { install, SCHEMA_VERSION };
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const net = require('node:net');
const { install, SCHEMA_VERSION } = require('./bootstrap.cjs');
test('Creator bootstrap executes async Editor code and keeps a three-command surface', async () => {
const endpoint =
process.platform === 'win32'
? `\\\\.\\pipe\\genarrative-cocos-test-${process.pid}`
: `/tmp/genarrative-cocos-test-${process.pid}.sock`;
const Editor = {
Project: { path: __dirname },
App: { version: 'test' },
Message: { request: async (channel, message) => ({ channel, message }) },
};
const bridge = install(Editor, { endpoint });
await bridge.ready;
let sequence = 0;
function request(command, override = {}) {
const envelope = {
schemaVersion: SCHEMA_VERSION,
requestId: `test-${++sequence}`,
processId: process.pid,
projectPath: __dirname,
command,
...override,
};
return new Promise((resolve, reject) => {
const socket = net.connect(endpoint);
let data = '';
socket.setTimeout(3000, () =>
socket.destroy(new Error('fixture timeout')),
);
socket.on('error', reject);
socket.on('connect', () => socket.write(`${JSON.stringify(envelope)}\n`));
socket.on('data', (chunk) => {
data += chunk.toString();
});
socket.on('end', () => resolve(JSON.parse(data)));
});
}
try {
assert.equal((await request({ op: 'ping' })).result.ready, true);
if (process.platform === 'win32') {
assert.equal(
(await request({ op: 'ping' }, { projectPath: `\\\\?\\${__dirname}` }))
.ok,
true,
);
}
assert.equal(
(await request({ op: 'status' })).result.creatorVersion,
'test',
);
const result = await request({
op: 'execute',
code: "return await Editor.Message.request('scene', 'query-node-tree');",
});
assert.equal(result.ok, true);
assert.deepEqual(result.result, {
channel: 'scene',
message: 'query-node-tree',
});
assert.equal((await request({ op: 'deleteScene' })).ok, false);
assert.equal((await request({ op: 'execute', code: '' })).ok, false);
assert.equal(
(await request({ op: 'execute', code: 'x'.repeat(128 * 1024 + 1) })).ok,
false,
);
assert.equal(
(await request({ op: 'execute', code: "throw new Error('expected');" }))
.ok,
false,
);
assert.equal(
(await request({ op: 'ping' }, { processId: process.pid + 1 })).ok,
false,
);
assert.equal(install(Editor, { endpoint }), bridge);
} finally {
await bridge.dispose();
}
});
@@ -0,0 +1,499 @@
//! Cocos Creator 专属编辑器适配器。
//!
//! 该实现随 Cocos 插件包分发,实现通用的 `editor_adapter_api::EditorAdapter`,
//! 由 AGC 宿主按插件 manifest 里的 `adapter` 字段注册和路由。宿主本身不包含
//! Cocos 进程名、注入或 Editor.Message 知识。
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use editor_adapter_api::{EditorAdapter, EditorConnectionInfo};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
discover_cocos_editors, execute_cocos_editor_code, execute_cocos_editor_code_for_project,
inject_bridge_dll, normalize_existing_directory, paths_equal, ping_cocos_editor,
status_cocos_editor, validate_execute_code, validate_injection_target,
CocosEditorInjectionRequest, DEFAULT_COMMAND_TIMEOUT_MS,
};
pub const COCOS_EDITOR_ADAPTER_ID: &str = "cocos-editor";
/// 通用 RPC 方法名到适配器方法的别名表。
pub const COCOS_EDITOR_RPC_METHODS: &[(&str, &str)] = &[
("editor.detect", "detect"),
("editor.connect", "connect"),
("editor.disconnect", "disconnect"),
("editor.prepare", "prepare"),
("editor.ping", "ping"),
("editor.status", "status"),
("editor.execute", "execute"),
("editor.inject", "inject"),
];
#[derive(Clone, Debug, Eq, PartialEq)]
struct CocosEditorConnection {
process_id: u32,
project_path: String,
creator_version: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AdapterRpcParams {
#[serde(default)]
process_id: Option<u32>,
#[serde(default)]
project_path: Option<String>,
#[serde(default)]
code: Option<String>,
#[serde(default)]
payload_path: Option<String>,
#[serde(default)]
timeout_ms: Option<u32>,
}
impl AdapterRpcParams {
fn from_value(params: Value) -> Result<Self, String> {
if params.is_null() {
return Ok(Self::default());
}
serde_json::from_value(params).map_err(|error| format!("插件 RPC 参数无效:{error}"))
}
fn timeout_ms(&self) -> u32 {
self.timeout_ms
.unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS)
.clamp(1, 60_000)
}
fn project_path(&self) -> Result<String, String> {
self.project_path
.clone()
.ok_or_else(|| "缺少 projectPath".to_string())
}
fn process_id(&self) -> Result<u32, String> {
self.process_id.ok_or_else(|| "缺少 processId".to_string())
}
}
/// 由插件包自带的 native 模块实现的 Cocos Creator 适配器。
#[derive(Debug)]
pub struct CocosEditorAdapter {
payload_candidates: Vec<PathBuf>,
connection: Mutex<Option<CocosEditorConnection>>,
// 独立于连接/插件进程生命周期,未知执行结果只能在人工核对后重启宿主恢复。
execution_uncertain: Mutex<bool>,
}
impl Default for CocosEditorAdapter {
fn default() -> Self {
Self::new(Vec::new())
}
}
impl CocosEditorAdapter {
pub fn new(payload_candidates: Vec<PathBuf>) -> Self {
Self {
payload_candidates,
connection: Mutex::new(None),
execution_uncertain: Mutex::new(false),
}
}
fn connection(&self) -> Result<Option<CocosEditorConnection>, String> {
self.connection
.lock()
.map(|connection| connection.clone())
.map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())
}
fn connected_info(&self, process: &crate::CocosEditorProcess) -> EditorConnectionInfo {
EditorConnectionInfo {
adapter: COCOS_EDITOR_ADAPTER_ID.to_string(),
connected: true,
pid: Some(process.process_id),
project_path: process.project_path.clone(),
version: process.creator_version.clone(),
}
}
fn connect_with(
&self,
process: crate::CocosEditorProcess,
handshake: impl FnOnce() -> Result<(), String>,
) -> Result<EditorConnectionInfo, String> {
let mut connection = self
.connection
.lock()
.map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())?;
*connection = None;
handshake()?;
let info = self.connected_info(&process);
*connection = Some(CocosEditorConnection {
process_id: process.process_id,
project_path: process
.project_path
.clone()
.ok_or_else(|| "目标缺少项目路径".to_string())?,
creator_version: process.creator_version.clone(),
});
Ok(info)
}
fn connect_target(&self, pid: u32, project_path: &str) -> Result<EditorConnectionInfo, String> {
let target =
validate_injection_target(pid, project_path).map_err(|error| error.to_string())?;
self.connect_with(target.clone(), || {
crate::ensure_target_ready(&target).map_err(|error| error.to_string())
})
}
/// 只允许加载插件包随包目录里的 payload,避免适配器变成任意 DLL 注入器。
fn payload_path(&self, requested: Option<&str>) -> Result<PathBuf, String> {
match requested {
Some(requested) => {
let requested = PathBuf::from(requested);
match requested
.extension()
.and_then(|extension| extension.to_str())
{
Some(extension) if extension.eq_ignore_ascii_case("dll") => {}
_ => return Err("Cocos bridge payload 必须是 .dll".to_string()),
}
let normalized = requested
.canonicalize()
.map_err(|_| "Cocos bridge payload 不可读".to_string())?;
let allowed = self.payload_candidates.iter().any(|candidate| {
candidate
.parent()
.and_then(|parent| parent.canonicalize().ok())
.is_some_and(|root| normalized.starts_with(root))
});
if !allowed {
return Err("Cocos bridge payload 只能来自插件包随包目录".to_string());
}
Ok(normalized)
}
None => self
.payload_candidates
.iter()
.find(|candidate| candidate.is_file())
.cloned()
.ok_or_else(|| "插件未随包提供 Cocos bridge payload".to_string()),
}
}
fn project_connection(
&self,
project_path: &str,
) -> Result<Option<CocosEditorConnection>, String> {
let connection = self.connection()?;
match connection {
Some(connection)
if paths_equal(Path::new(&connection.project_path), Path::new(project_path)) =>
{
Ok(Some(connection))
}
Some(_) => Err("适配器已连接到其它项目".to_string()),
None => Ok(None),
}
}
fn rpc_prepare(&self, params: &AdapterRpcParams) -> Result<Value, String> {
let project_path = params.project_path()?;
let process = validate_injection_target(params.process_id()?, &project_path)
.map_err(|error| error.to_string())?;
serde_json::to_value(process).map_err(|error| error.to_string())
}
fn rpc_execute(&self, params: &AdapterRpcParams) -> Result<Value, String> {
let project_path = params.project_path()?;
let code = params.code.clone().ok_or_else(|| "缺少 code".to_string())?;
validate_execute_code(&code).map_err(|error| error.to_string())?;
let timeout_ms = params.timeout_ms();
let connection = self.project_connection(&project_path)?;
self.execute_with(|| match connection {
Some(connection) => execute_cocos_editor_code(
connection.process_id,
&connection.project_path,
&code,
timeout_ms,
),
None => execute_cocos_editor_code_for_project(&project_path, &code, timeout_ms),
})
}
fn execute_with(
&self,
execute: impl FnOnce() -> Result<crate::CocosEditorCommandResponse, crate::BridgeError>,
) -> Result<Value, String> {
let mut uncertain = self
.execution_uncertain
.lock()
.map_err(|_| "Cocos 执行状态不可用,执行结果需要核对".to_string())?;
if *uncertain {
return Ok(
json!({"ok": false, "status": "needs-reconciliation", "retryAllowed": false,
"error": "先前 Cocos execute 结果待核对,当前适配器不再发送执行命令"}),
);
}
// 持锁串行执行,后续调用必须先观察前一次是否产生不确定结果。
match execute() {
Ok(response) => serde_json::to_value(response).map_err(|error| error.to_string()),
Err(error) => {
*uncertain = matches!(&error, crate::BridgeError::ExecutionUncertain(_));
Ok(json!({"ok": false,
"status": if *uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !*uncertain, "error": error.to_string()}))
}
}
}
fn rpc_inject(&self, params: &AdapterRpcParams) -> Result<Value, String> {
let project_path = params.project_path()?;
let payload = self.payload_path(params.payload_path.as_deref())?;
let request = CocosEditorInjectionRequest {
process_id: params.process_id()?,
project_path,
bridge_dll_path: payload.to_string_lossy().into_owned(),
timeout_ms: params
.timeout_ms
.unwrap_or(crate::DEFAULT_INJECTION_TIMEOUT_MS),
};
let result = inject_bridge_dll(&request).map_err(|error| error.to_string())?;
serde_json::to_value(result).map_err(|error| error.to_string())
}
}
fn canonical_method(method: &str) -> Option<&'static str> {
let trimmed = method.trim();
if let Some((_, canonical)) = COCOS_EDITOR_RPC_METHODS
.iter()
.find(|(alias, _)| *alias == trimmed)
{
return Some(canonical);
}
COCOS_EDITOR_RPC_METHODS
.iter()
.find(|(_, canonical)| *canonical == trimmed)
.map(|(_, canonical)| *canonical)
}
impl EditorAdapter for CocosEditorAdapter {
fn id(&self) -> &'static str {
COCOS_EDITOR_ADAPTER_ID
}
fn detect(&self, project_path: &Path) -> Result<EditorConnectionInfo, String> {
let project_path =
normalize_existing_directory(project_path).map_err(|error| error.to_string())?;
let discovery = discover_cocos_editors().map_err(|error| error.to_string())?;
let mut matches = discovery.processes.into_iter().filter(|process| {
process.project_path.as_deref().is_some_and(|candidate| {
paths_equal(Path::new(candidate), Path::new(&project_path))
})
});
let target = matches
.next()
.ok_or_else(|| "当前项目没有已打开的 Cocos Creator 主进程".to_string())?;
if matches.next().is_some() {
return Err("当前项目匹配到多个 Cocos Creator 主进程".to_string());
}
Ok(EditorConnectionInfo {
adapter: COCOS_EDITOR_ADAPTER_ID.to_string(),
connected: false,
pid: Some(target.process_id),
project_path: target.project_path,
version: target.creator_version,
})
}
fn connect(
&mut self,
pid: u32,
project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
self.connect_target(pid, &project_path.to_string_lossy())
}
fn disconnect(&mut self) {
if let Ok(mut connection) = self.connection.lock() {
*connection = None;
}
}
fn translate_rpc(&self, method: &str, params: Value) -> Result<Value, String> {
let canonical = canonical_method(method)
.ok_or_else(|| format!("Cocos 适配器不支持 RPC 方法:{method}"))?;
Ok(json!({ "method": canonical, "params": params }))
}
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
let canonical = canonical_method(method)
.ok_or_else(|| format!("Cocos 适配器不支持 RPC 方法:{method}"))?;
let params = AdapterRpcParams::from_value(params)?;
match canonical {
"detect" => {
let project_path = params.project_path()?;
let info = self.detect(Path::new(&project_path))?;
serde_json::to_value(info).map_err(|error| error.to_string())
}
"connect" => {
let project_path = params.project_path()?;
let info = self.connect_target(params.process_id()?, &project_path)?;
serde_json::to_value(info).map_err(|error| error.to_string())
}
"disconnect" => {
if let Ok(mut connection) = self.connection.lock() {
*connection = None;
}
Ok(json!({ "connected": false }))
}
"prepare" => self.rpc_prepare(&params),
"ping" | "status" => {
let project_path = params.project_path()?;
let process_id = params.process_id()?;
let timeout_ms = params.timeout_ms();
let response = if canonical == "ping" {
ping_cocos_editor(process_id, &project_path, timeout_ms)
} else {
status_cocos_editor(process_id, &project_path, timeout_ms)
}
.map_err(|error| error.to_string())?;
serde_json::to_value(response).map_err(|error| error.to_string())
}
"execute" => self.rpc_execute(&params),
"inject" => self.rpc_inject(&params),
other => Err(format!("Cocos 适配器不支持 RPC 方法:{other}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_is_not_published_before_a_successful_handshake() {
let adapter = CocosEditorAdapter::default();
let target = crate::CocosEditorProcess {
process_id: 42,
parent_process_id: 1,
executable_path: "CocosCreator.exe".into(),
command_line: None,
project_path: Some("C:/project".into()),
creator_version: Some("3.8.8".into()),
};
assert!(adapter
.connect_with(target.clone(), || Err("pipe unavailable".into()))
.is_err());
assert!(adapter.connection().unwrap().is_none());
assert!(adapter.connect_with(target, || Ok(())).unwrap().connected);
assert_eq!(adapter.connection().unwrap().unwrap().process_id, 42);
}
#[test]
fn uncertain_execute_blocks_subsequent_dispatch_even_after_disconnect() {
let mut adapter = CocosEditorAdapter::default();
let result = adapter
.execute_with(|| {
Err(crate::BridgeError::ExecutionUncertain(
"timeout".to_string(),
))
})
.unwrap();
assert_eq!(result["status"], "needs-reconciliation");
assert_eq!(result["retryAllowed"], false);
adapter.disconnect();
let result = adapter
.execute_with(|| panic!("must not dispatch again"))
.unwrap();
assert_eq!(result["retryAllowed"], false);
}
#[test]
fn pre_dispatch_errors_do_not_latch_reconciliation() {
let adapter = CocosEditorAdapter::default();
for _ in 0..2 {
let result = adapter
.execute_with(|| Err(crate::BridgeError::TargetNotFound(42)))
.unwrap();
assert_eq!(result["status"], "failed");
assert_eq!(result["retryAllowed"], true);
}
}
#[test]
fn adapter_id_matches_plugin_manifest_adapter() {
let adapter = CocosEditorAdapter::default();
assert_eq!(adapter.id(), "cocos-editor");
}
#[test]
fn translate_rpc_accepts_aliases_and_rejects_unknown_methods() {
let adapter = CocosEditorAdapter::default();
let translated = adapter
.translate_rpc("editor.execute", json!({"code": "return 1;"}))
.expect("alias must translate");
assert_eq!(translated["method"], "execute");
assert!(adapter.translate_rpc("editor.eval", json!({})).is_err());
assert!(adapter.rpc("editor.unknown", json!({})).is_err());
}
#[test]
fn execute_validates_code_before_touching_the_editor() {
let adapter = CocosEditorAdapter::default();
let error = adapter
.rpc("execute", json!({"projectPath": "C:\\demo", "code": " "}))
.expect_err("empty code must fail");
assert!(error.contains("不能为空"), "unexpected error: {error}");
}
#[test]
fn connect_rejects_process_id_zero() {
let adapter = CocosEditorAdapter::default();
let error = adapter
.rpc(
"connect",
json!({"processId": 0, "projectPath": "C:\\demo"}),
)
.expect_err("processId 0 must fail");
assert!(error.contains("processId"), "unexpected error: {error}");
}
#[test]
fn detect_fails_closed_for_missing_project_directory() {
let adapter = CocosEditorAdapter::default();
let missing = std::env::temp_dir().join("agc-cocos-adapter-missing-project");
let error = adapter
.detect(&missing)
.expect_err("missing project must fail closed");
assert!(!error.is_empty());
}
#[test]
fn inject_rejects_payload_outside_the_plugin_package() {
let adapter = CocosEditorAdapter::default();
let outside = std::env::temp_dir().join("agc-cocos-adapter-outside-payload.dll");
std::fs::write(&outside, b"not-a-real-payload").expect("write probe payload");
let error = adapter
.rpc(
"inject",
json!({
"processId": 12,
"projectPath": "C:\\demo",
"payloadPath": outside.to_string_lossy(),
}),
)
.expect_err("payload outside the plugin package must fail");
let _ = std::fs::remove_file(&outside);
assert!(
error.contains("只能来自插件包随包目录"),
"unexpected error: {error}"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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__"));
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@genarrative/agc-plugin-cocos-editor",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "AGC Cocos Creator 编辑器插件",
"scripts": {
"test": "node --test src/entry.test.mjs src/operations/operations.test.cjs"
},
"dependencies": {
"@genarrative/agc-plugin-sdk": "0.1.0"
}
}
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Cocos Creator</title>
<style>
:root {
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', sans-serif;
}
body {
margin: 0;
padding: 14px 16px;
font-size: 13px;
line-height: 1.6;
color: #1f2329;
background: #fff;
}
@media (prefers-color-scheme: dark) {
body {
color: #e8eaed;
background: #1b1c1f;
}
.card {
border-color: #33353a;
background: #232529;
}
}
h1 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.card {
border: 1px solid #e5e6eb;
border-radius: 8px;
padding: 10px 12px;
background: #f7f8fa;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
opacity: 0.65;
}
dd {
margin: 0;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>Cocos Creator</h1>
<div class="card">
<dl>
<dt>插件</dt>
<dd><code>agc-cocos-editor</code></dd>
<dt>适配器</dt>
<dd><code>cocos-editor</code></dd>
<dt>命令</dt>
<dd><code>cocos.editor.execute</code></dd>
<dt>能力</dt>
<dd><code>cocos.editor.connection</code></dd>
</dl>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "agc-cocos-editor",
"version": "0.1.0",
"description": "在用户已打开的 Cocos Creator 项目中执行受控编辑器操作",
"extensions": {
"com.openai": {
"interface": {
"displayName": "Cocos Creator 编辑器桥接"
}
},
"world.genarrative.agc": {
"apiVersion": "v1",
"entry": "./src/entry.mjs",
"adapter": "cocos-editor",
"permissions": [
"events.subscribe",
"editor.rpc",
"ui.register",
"capability.register"
],
"panels": [
{
"id": "cocos-editor",
"title": "Cocos Creator",
"entry": "./panels/cocos-editor.html",
"placement": "sidebar"
}
]
}
}
}
@@ -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,102 @@
/**
* Cocos 插件侧适配器翻译层。
*
* 该模块只做“通用插件请求 → Cocos 适配器请求”的翻译与入参校验,不直接接触
* 进程、DLL 或 named pipe;原生部分由插件包内的 native/cocos-editor-bridge
* 实现,并通过宿主 `host.rpc` 路由过去。
*/
export const COCOS_EDITOR_ADAPTER_ID = 'cocos-editor';
/** 与 native crate 的 MAX_EXECUTE_CODE_BYTES 保持一致。 */
export const COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES = 128 * 1024;
export const COCOS_EDITOR_MAX_TIMEOUT_MS = 60_000;
export const COCOS_EDITOR_OPERATIONS = Object.freeze([
'detect',
'connect',
'disconnect',
'prepare',
'ping',
'status',
'execute',
'inject',
]);
const REQUIRED_PARAMS = Object.freeze({
detect: ['projectPath'],
connect: ['processId', 'projectPath'],
disconnect: [],
prepare: ['processId', 'projectPath'],
ping: ['processId', 'projectPath'],
status: ['processId', 'projectPath'],
execute: ['projectPath', 'code'],
inject: ['processId', 'projectPath'],
});
export function validateExecuteCode(code) {
if (typeof code !== 'string' || code.trim().length === 0) {
throw new Error('execute.code 不能为空');
}
if (code.includes('\u0000')) {
throw new Error('execute.code 不能包含 NUL');
}
const bytes = Buffer.byteLength(code, 'utf8');
if (bytes > COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES) {
throw new Error(
`execute.code 超过 ${COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES} 字节上限`,
);
}
}
function assertTimeout(timeoutMs) {
if (timeoutMs === undefined || timeoutMs === null) return undefined;
if (
!Number.isInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > COCOS_EDITOR_MAX_TIMEOUT_MS
) {
throw new Error(`timeoutMs 必须在 1..=${COCOS_EDITOR_MAX_TIMEOUT_MS}`);
}
return timeoutMs;
}
/**
* 生成 `host.rpc` 的编辑器请求。
*
* @param {string} operation COCOS_EDITOR_OPERATIONS 之一
* @param {object} [rawParams] projectPath / processId / code / timeoutMs / payloadPath
*/
export function buildEditorRpcRequest(operation, rawParams = {}) {
if (!COCOS_EDITOR_OPERATIONS.includes(operation)) {
throw new Error(`不支持的 Cocos 适配器操作:${operation}`);
}
const required = REQUIRED_PARAMS[operation] ?? [];
const params = {};
for (const name of required) {
const value = rawParams[name];
// code 由 validateExecuteCode 给出更精确的空值 / 长度 / NUL 诊断。
if (
value === undefined ||
value === null ||
(name !== 'code' && value === '')
) {
throw new Error(`Cocos 适配器操作 ${operation} 缺少 ${name}`);
}
params[name] = value;
}
if (operation === 'execute') {
validateExecuteCode(rawParams.code);
params.code = rawParams.code;
}
const timeoutMs = assertTimeout(rawParams.timeoutMs);
if (timeoutMs !== undefined) params.timeoutMs = timeoutMs;
if (typeof rawParams.payloadPath === 'string' && rawParams.payloadPath) {
if (operation !== 'inject') {
throw new Error('payloadPath 只允许用于 inject');
}
params.payloadPath = rawParams.payloadPath;
}
return { method: `editor.${operation}`, params };
}
@@ -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;
}
+300
View File
@@ -0,0 +1,300 @@
/**
* AGC Cocos Creator 插件运行时入口。
*
* 宿主以插件目录为 cwd 用系统 `node` 启动本文件,stdio 上使用一行一个 JSON-RPC
* 2.0 消息(`agc.plugin.v1`)。入口只做注册与转发:命令、能力、面板通过通用宿主
* 注册,编辑器操作经 `host.rpc` 路由到插件包自带 native 适配器。
*
* 说明:通用 SDK `@genarrative/agc-plugin-sdk` 目前以 TypeScript 源码分发,而运行
* 入口必须能被系统 node 直接执行,因此这里内联同一份 stdio 协议实现;package.json
* 仍声明 SDK 版本依赖,测试会校验协议常量、方法名与 native 适配器一致。
*/
import { createInterface } from 'node:readline';
import { pathToFileURL } from 'node:url';
import {
buildEditorRpcRequest,
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',
title: 'Cocos Creator',
entry: './panels/cocos-editor.html',
placement: 'sidebar',
});
export const PROJECT_CHANGED_EVENT = 'project.changed';
// 默认执行可先安装桥接;覆盖 15 秒引导、最长 60 秒命令和发现开销,早于宿主 90 秒截止。
const RPC_TIMEOUT_MS = 85_000;
export function createCocosEditorPlugin({
send,
log = () => undefined,
timeoutMs = RPC_TIMEOUT_MS,
}) {
let nextId = 1;
let activeProjectPath = null;
let disposed = false;
let executionUncertain = false;
let executionPending = false;
const pending = new Map();
const handlers = new Map([
[COCOS_EXECUTE_COMMAND_ID, handleExecute],
[COCOS_OPERATION_COMMAND_ID, handleOperation],
[COCOS_CONNECTION_CAPABILITY_ID, handleConnection],
]);
function request(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`插件宿主 RPC 超时:${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
send({ jsonrpc: '2.0', id, method, params });
});
}
function respond(id, result, error) {
if (error) {
send({
jsonrpc: '2.0',
id,
error: { code: -32603, message: error },
});
return;
}
send({ jsonrpc: '2.0', id, result: result ?? null });
}
function resolveProjectPath(params) {
const explicit = params?.projectPath;
if (typeof explicit === 'string' && explicit) return explicit;
if (activeProjectPath) return activeProjectPath;
throw new Error('当前没有受控项目路径,宿主需先设置项目上下文');
}
async function callEditor(operation, params) {
const { method, params: editorParams } = buildEditorRpcRequest(
operation,
params,
);
return request('host.rpc', { method, params: editorParams });
}
async function handleExecute(params) {
const code = params?.code;
validateExecuteCode(code);
const projectPath = resolveProjectPath(params);
const reconcile = (response) => ({
status: 'needs-reconciliation',
retryAllowed: false,
response,
});
if (executionUncertain)
return reconcile({ ok: false, error: '先前执行结果待核对' });
// 不积压稍后执行的 mutation,避免调用方超时后请求仍从队列发出。
if (executionPending)
return {
status: 'failed',
retryAllowed: false,
response: {
ok: false,
error: '已有 Cocos execute 正在执行,请等待回执',
},
};
executionPending = true;
try {
const response = await callEditor('execute', {
projectPath,
code,
timeoutMs: params?.timeoutMs,
});
if (response?.status === 'needs-reconciliation') {
executionUncertain = true;
return reconcile(response);
}
if (typeof response?.ok !== 'boolean')
throw new Error('宿主缺少可信执行回执');
return { status: response.ok ? 'completed' : 'failed', response };
} catch (error) {
// 已交给宿主的 execute 超时/断线不能推断为未执行。
executionUncertain = true;
return reconcile({ ok: false, error: error.message });
} finally {
executionPending = false;
}
}
async function handleConnection(params) {
const operation = params?.operation ?? 'detect';
if (!COCOS_EDITOR_OPERATIONS.includes(operation)) {
throw new Error(`不支持的能力操作:${operation}`);
}
if (operation === 'execute') return handleExecute(params);
if (operation === 'disconnect') {
return callEditor('disconnect', {});
}
return callEditor(operation, {
projectPath: resolveProjectPath(params),
processId: params?.processId,
timeoutMs: params?.timeoutMs,
payloadPath: params?.payloadPath,
});
}
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 =
typeof message === 'string' ? JSON.parse(message) : message;
if (!envelope || envelope.jsonrpc !== '2.0') return;
if (envelope.method === 'host.event') {
const event = envelope.params ?? {};
if (event.type === PROJECT_CHANGED_EVENT) {
const projectPath = event.payload?.projectPath;
activeProjectPath =
typeof projectPath === 'string' && projectPath ? projectPath : null;
}
return;
}
if (envelope.method !== undefined) {
if (envelope.id === undefined) return;
const handler = handlers.get(envelope.method);
if (!handler) {
respond(envelope.id, undefined, `插件未注册方法:${envelope.method}`);
return;
}
try {
respond(envelope.id, await handler(envelope.params ?? {}));
} catch (error) {
log(`cocos plugin 处理 ${envelope.method} 失败:${error.message}`);
respond(envelope.id, undefined, error.message);
}
return;
}
const request = pending.get(envelope.id);
if (!request) return;
pending.delete(envelope.id);
clearTimeout(request.timer);
if (envelope.error) {
request.reject(new Error(envelope.error.message ?? '插件宿主 RPC 失败'));
return;
}
request.resolve(envelope.result);
}
async function start() {
const command = await request('host.registerCommand', {
id: COCOS_EXECUTE_COMMAND_ID,
title: '在 Cocos Creator 中执行代码',
description: '在已打开的 Cocos Creator 项目中执行受控 JavaScript 函数体',
});
const capability = await request('host.registerCapability', {
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,
});
const subscription = await request('host.events.subscribe', {
type: PROJECT_CHANGED_EVENT,
});
activeProjectPath = subscription?.projectPath ?? null;
return {
command,
capability,
operations,
panel,
subscriptionId: subscription?.subscriptionId ?? null,
};
}
function dispose() {
disposed = true;
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error('插件已停止'));
}
pending.clear();
}
return {
start,
handleMessage,
dispose,
get activeProjectPath() {
return activeProjectPath;
},
};
}
export function startCocosEditorStdioPlugin({
stdin = process.stdin,
stdout = process.stdout,
log = (message) => process.stderr.write(`${message}\n`),
} = {}) {
const plugin = createCocosEditorPlugin({
send: (message) => stdout.write(`${JSON.stringify(message)}\n`),
log,
});
const lines = createInterface({ input: stdin, crlfDelay: Infinity });
lines.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed) return;
void plugin.handleMessage(trimmed).catch((error) => {
log(`cocos plugin 消息处理失败:${error.message}`);
});
});
void plugin.start().catch((error) => {
log(`cocos plugin 注册失败:${error.message}`);
});
return plugin;
}
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
startCocosEditorStdioPlugin();
}
+416
View File
@@ -0,0 +1,416 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
buildEditorRpcRequest,
COCOS_EDITOR_OPERATIONS,
validateExecuteCode,
} from './cocos-editor-adapter.mjs';
import {
COCOS_CONNECTION_CAPABILITY_ID,
COCOS_EDITOR_PANEL,
COCOS_EXECUTE_COMMAND_ID,
COCOS_OPERATION_COMMAND_ID,
COCOS_PLUGIN_PROTOCOL_VERSION,
createCocosEditorPlugin,
PROJECT_CHANGED_EVENT,
} from './entry.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const pluginRoot = path.join(here, '..');
const tick = () => new Promise((resolve) => setImmediate(resolve));
function createHarness(timeoutMs) {
const outbound = [];
const plugin = createCocosEditorPlugin({
timeoutMs,
send: (message) => outbound.push(structuredClone(message)),
});
const respond = (id, result) =>
plugin.handleMessage({ jsonrpc: '2.0', id, result });
return { plugin, outbound, respond };
}
async function startPlugin(harness, projectPath = 'C:\\demo') {
const started = harness.plugin.start();
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, { 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,
});
return started;
}
test('runtime entry registers command, capability, panel and project event', async () => {
const harness = createHarness();
const result = await startPlugin(harness);
const methods = harness.outbound.map((message) => message.method);
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.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);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 77,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return Editor.Project.path;' },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.equal(rpc.method, 'host.rpc');
assert.deepEqual(rpc.params, {
method: 'editor.execute',
params: {
projectPath: 'C:\\demo',
code: 'return Editor.Project.path;',
},
});
harness.respond(rpc.id, { ok: true, requestId: 'cocos-1' });
await handling;
const reply = harness.outbound.at(-1);
assert.equal(reply.id, 77);
assert.equal(reply.result.status, 'completed');
});
test('execute command fails closed without a project or with invalid code', async () => {
const harness = createHarness();
await startPlugin(harness, null);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 1,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
assert.match(harness.outbound.at(-1).error.message, /项目路径/);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 2,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: ' ', projectPath: 'C:\\demo' },
});
assert.match(harness.outbound.at(-1).error.message, /不能为空/);
});
test('connection capability dispatches adapter operations', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 5,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'ping', processId: 42 },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.deepEqual(rpc.params, {
method: 'editor.ping',
params: { processId: 42, projectPath: 'C:\\demo' },
});
harness.respond(rpc.id, { ok: true });
await handling;
assert.equal(harness.outbound.at(-1).result.ok, true);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 6,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'eval' },
});
assert.match(harness.outbound.at(-1).error.message, /不支持的能力操作/);
});
test('project.changed event updates the cached project path', async () => {
const harness = createHarness();
await startPlugin(harness);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
method: 'host.event',
params: {
type: PROJECT_CHANGED_EVENT,
payload: { projectPath: 'D:\\other' },
},
});
assert.equal(harness.plugin.activeProjectPath, 'D:\\other');
await harness.plugin.handleMessage({
jsonrpc: '2.0',
method: 'host.event',
params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath: null } },
});
assert.equal(harness.plugin.activeProjectPath, null);
});
test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 71,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const second = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 72,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
await tick();
const requests = harness.outbound.filter(
(item) => item.method === 'host.rpc',
);
assert.equal(requests.length, 1);
await harness.respond(requests[0].id, {
ok: false,
status: 'needs-reconciliation',
retryAllowed: false,
});
await Promise.all([first, second]);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 73,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 3;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(
harness.outbound.find((item) => item.id === 72 && item.result).result
.retryAllowed,
false,
);
for (const id of [71, 73]) {
const reply = harness.outbound.find(
(item) => item.id === id && item.result,
);
assert.equal(reply.result.status, 'needs-reconciliation');
assert.equal(reply.result.retryAllowed, false);
}
});
test('host RPC failure blocks later execute without resending', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 81,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
await tick();
const rpc = harness.outbound.at(-1);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: rpc.id,
error: { message: 'connection closed' },
});
await first;
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 82,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('execute timeout keeps later requests blocked even after a late success', async () => {
const harness = createHarness(100);
await startPlugin(harness);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 91,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const rpc = harness.outbound.find((item) => item.method === 'host.rpc');
assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation');
await harness.respond(rpc.id, { ok: true });
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 92,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'execute', code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('adapter request builder enforces per-operation parameters', () => {
assert.deepEqual(
buildEditorRpcRequest('status', {
processId: 7,
projectPath: 'C:\\demo',
}),
{
method: 'editor.status',
params: { processId: 7, projectPath: 'C:\\demo' },
},
);
assert.throws(
() =>
buildEditorRpcRequest('execute', { projectPath: 'C:\\demo', code: '' }),
/不能为空/,
);
assert.deepEqual(
buildEditorRpcRequest('inject', {
processId: 1,
projectPath: 'C:\\demo',
payloadPath: 'C:\\payload\\cocos-editor-bridge.dll',
}).params.payloadPath,
'C:\\payload\\cocos-editor-bridge.dll',
);
assert.throws(
() =>
buildEditorRpcRequest('ping', {
processId: 1,
projectPath: 'C:\\demo',
payloadPath: 'C:\\payload\\cocos-editor-bridge.dll',
}),
/payloadPath/,
);
assert.throws(() => buildEditorRpcRequest('detect', {}), /缺少 projectPath/);
assert.throws(() => buildEditorRpcRequest('eval', {}), /不支持/);
assert.throws(() => validateExecuteCode('x'.repeat(128 * 1024 + 1)), /上限/);
});
test('manifest, native adapter and SDK agree on ids and protocol', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8'),
);
assert.equal(
manifest.$schema,
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
);
assert.equal(manifest.name, 'agc-cocos-editor');
const runtime = manifest.extensions['world.genarrative.agc'];
assert.equal(runtime.apiVersion, 'v1');
assert.equal(runtime.adapter, 'cocos-editor');
assert.equal(runtime.entry, './src/entry.mjs');
assert.ok(fs.existsSync(path.join(pluginRoot, runtime.entry)));
assert.deepEqual(
runtime.panels.map((panel) => panel.id),
[COCOS_EDITOR_PANEL.id],
);
for (const panel of runtime.panels) {
assert.ok(
fs.existsSync(path.join(pluginRoot, panel.entry)),
`panel entry missing: ${panel.entry}`,
);
}
assert.ok(runtime.permissions.includes('editor.rpc'));
assert.ok(runtime.permissions.includes('capability.register'));
const nativeAdapter = fs.readFileSync(
path.join(pluginRoot, 'native/cocos-editor-bridge/src/adapter.rs'),
'utf8',
);
const nativeOperations = [
...nativeAdapter.matchAll(/\("editor\.([a-z]+)", "([a-z]+)"\)/gu),
].map((match) => match[1]);
assert.deepEqual(nativeOperations, [...COCOS_EDITOR_OPERATIONS]);
assert.ok(nativeAdapter.includes(`"${runtime.adapter}"`));
const sdk = fs.readFileSync(
path.join(pluginRoot, '../../packages/agc-plugin-sdk/src/index.ts'),
'utf8',
);
assert.ok(
sdk.includes(
`AGC_PLUGIN_PROTOCOL_VERSION = '${COCOS_PLUGIN_PROTOCOL_VERSION}'`,
),
);
const host = fs.readFileSync(
path.join(
pluginRoot,
'../../apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs',
),
'utf8',
);
assert.ok(host.includes(`"${COCOS_PLUGIN_PROTOCOL_VERSION}"`));
assert.ok(host.includes(`"${runtime.apiVersion}"`));
});
@@ -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 };
};

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