接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 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:
@@ -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/` 里的插件或编辑器方案文档。
|
||||
@@ -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(¶ms),
|
||||
"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(¶ms),
|
||||
"inject" => self.rpc_inject(¶ms),
|
||||
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__"));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user